diff --git a/.github/workflows/core-compat-audit.yml b/.github/workflows/core-compat-audit.yml index 705e16d..0f00cfe 100644 --- a/.github/workflows/core-compat-audit.yml +++ b/.github/workflows/core-compat-audit.yml @@ -5,6 +5,7 @@ on: paths: - 'bright_vision_core/**' - 'cecli/**' + - 'brightdate-python/**' - 'src/**' - 'src-tauri/**' - 'docs/**' @@ -24,9 +25,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - - name: Install bright_vision_core + cecli (editable) + - name: Install brightdate + cecli + bright_vision_core (editable) run: | python -m pip install -U pip + pip install -e brightdate-python pip install -e cecli pip install -e '.[dev]' - name: workspace path migration tests diff --git a/.gitmodules b/.gitmodules index 043896b..66f6840 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "brightdate-python"] + path = brightdate-python + url = https://github.com/Digital-Defiance/brightdate-python.git + branch = main [submodule "cecli"] path = cecli url = https://github.com/Digital-Defiance/cecli.git diff --git a/.pnp.cjs b/.pnp.cjs index 2a4057f..bb66bfb 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2650,6 +2650,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@brightchain/brightdate", [\ + ["npm:0.36.0", {\ + "packageLocation": "../../../Users/jessica/.yarn/berry/cache/@brightchain-brightdate-npm-0.36.0-f1515b8c3a-10c0.zip/node_modules/@brightchain/brightdate/",\ + "packageDependencies": [\ + ["@brightchain/brightdate", "npm:0.36.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@brightvision/remote", [\ ["workspace:apps/remote", {\ "packageLocation": "./apps/remote/",\ @@ -2672,6 +2682,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./apps/test-lab/",\ "packageDependencies": [\ ["@brightvision/test-lab", "workspace:apps/test-lab"],\ + ["@brightvision/vision-client", "workspace:packages/vision-client"],\ ["@emotion/react", "virtual:5afbcf4b4292869aa604dff1dc4dfe9a10ab988436177d12b79be9ba686c3d170f4166cb69fafba4a1aa00c6f7d595d87954fd7c56abbd5ed7217892b325635e#npm:11.14.0"],\ ["@emotion/styled", "virtual:5afbcf4b4292869aa604dff1dc4dfe9a10ab988436177d12b79be9ba686c3d170f4166cb69fafba4a1aa00c6f7d595d87954fd7c56abbd5ed7217892b325635e#npm:11.14.1"],\ ["@mui/icons-material", "virtual:5afbcf4b4292869aa604dff1dc4dfe9a10ab988436177d12b79be9ba686c3d170f4166cb69fafba4a1aa00c6f7d595d87954fd7c56abbd5ed7217892b325635e#npm:6.5.0"],\ @@ -2695,6 +2706,7 @@ const RAW_RUNTIME_STATE = ["workspace:packages/vision-client", {\ "packageLocation": "./packages/vision-client/",\ "packageDependencies": [\ + ["@brightchain/brightdate", "npm:0.36.0"],\ ["@brightvision/vision-client", "workspace:packages/vision-client"],\ ["typescript", "patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5"],\ ["vitest", "virtual:65071057716c6e2696ebe327c2c40600629ed88ed2bce3e7c12beafd26c55fdd764e9b0627dddecf9bff0b83f5ccf0fefe42f56a3639f90720d3752303316598#npm:2.1.9"]\ diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 602e06e..9142501 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/AGENTS.md b/AGENTS.md index 826a7f2..9a41583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Prioritize dogfoodable workflows: run `yarn dogfood:agent` (headless, no GUI req | **`src-tauri/`** | Tauri v2 shell — spawn Vision API, git, local LLM (Ollama), file dialogs | | **`bright_vision_core/`** | **Vision API** (parent repo) — `http_api`, `Session`, `git_workspace`, todos, SSE | | **`cecli/`** | **Cecli** submodule — [Digital-Defiance/cecli](https://github.com/Digital-Defiance/cecli) | +| **`brightdate-python/`** | **brightdate** PyPI slice — [Digital-Defiance/brightdate-python](https://github.com/Digital-Defiance/brightdate-python) | | **`scripts/vision_serve.py`** | Tauri spawn → `bright-vision-core-serve` on `:8741` | | **`docs/`** | Architecture, ROADMAP, LOCAL_LLM | | **`e2e/`** | Playwright (mocked `/api/core` + optional mocked Tauri) | @@ -42,7 +43,7 @@ React (src/) - **Do not** break `src/ipc/events.ts` without updating the shell in the same change — payloads must match `bright_vision_core` SSE (see `docs/IPC.md`). - **Desktop:** Tauri `start_core_api` runs `scripts/vision_serve.py` (repo root) → `bright-vision-core-serve` on `127.0.0.1:8741`. - **Web:** `bright-vision-core-serve` or Vite proxy `/api/core` → `:8741`. -- **Dev Python:** `source activate.sh` → `pip install -e` cecli submodule + parent `bright_vision_core` (`pip install -e .`). +- **Dev Python:** `source activate.sh` → `pip install -e` brightdate-python + cecli submodules + parent `bright_vision_core` (`pip install -e .`). Deeper detail: `docs/ARCHITECTURE.md`, `docs/IPC.md`, `docs/DEVELOPMENT.md`, `docs/LOCAL_LLM.md`, `docs/TESTING_POLICY.md`. diff --git a/activate.sh b/activate.sh index 9ecf871..69d2f8c 100755 --- a/activate.sh +++ b/activate.sh @@ -1,7 +1,12 @@ #!/usr/bin/env sh # Dev: editable Cecli (submodule) + bright_vision_core (parent package). # Safe to source: does not enable set -e in your interactive shell. -# When sourced from scripts/lab.sh, $0 is lab.sh — use BRIGHT_VISION_ROOT / BV_ROOT / BASH_SOURCE. +# When sourced: zsh/BSH use %x; bash uses BASH_SOURCE; lab.sh sets BRIGHT_VISION_ROOT / BV_ROOT. +# BSH (https://bsh.digitaldefiance.org) is a zsh fork — exposes BSH_VERSION, not ZSH_VERSION. +_is_zsh_family() { + [ -n "${ZSH_VERSION:-}" ] || [ -n "${BSH_VERSION:-}" ] +} + _resolve_repo_root() { if [ -n "${BRIGHT_VISION_ROOT:-}" ] && [ -d "${BRIGHT_VISION_ROOT}/bright_vision_core" ]; then cd "${BRIGHT_VISION_ROOT}" && pwd @@ -11,9 +16,29 @@ _resolve_repo_root() { cd "${BV_ROOT}" && pwd return 0 fi - if [ -n "${BASH_VERSION:-}" ] && [ -n "${BASH_SOURCE[0]:-}" ]; then - cd "$(dirname "${BASH_SOURCE[0]}")" && pwd - return 0 + # zsh / BSH: $0 stays the interactive shell when sourced; %x is usually this file. + if _is_zsh_family; then + _zsh_src="${(%):-%x}" + case "$_zsh_src" in + bsh | zsh | bash | sh | ksh | dash | "" | -bsh | -zsh | -bash) _zsh_src="" ;; + esac + if [ -z "$_zsh_src" ] && [ -n "${funcfiletrace[1]:-}" ]; then + _zsh_src="${funcfiletrace[1]%%:*}" + fi + if [ -n "$_zsh_src" ]; then + _zsh_dir="$(cd "$(dirname "$_zsh_src")" 2>/dev/null && pwd)" || _zsh_dir="" + if [ -n "$_zsh_dir" ] && [ -d "${_zsh_dir}/bright_vision_core" ]; then + echo "$_zsh_dir" + return 0 + fi + fi + fi + if [ -n "${BASH_SOURCE[0]:-}" ]; then + _bash_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || _bash_dir="" + if [ -n "$_bash_dir" ] && [ -d "${_bash_dir}/bright_vision_core" ]; then + echo "$_bash_dir" + return 0 + fi fi case "$0" in */activate.sh | ./activate.sh | activate.sh) @@ -21,12 +46,21 @@ _resolve_repo_root() { return 0 ;; esac + # sh/dash: executed as ./activate.sh (not sourced). + # Already in repo root (common when: cd BrightVision && source ./activate.sh). + if [ -d "./bright_vision_core" ] && [ -f "./pyproject.toml" ]; then + pwd + return 0 + fi return 1 } ROOT="$(_resolve_repo_root)" || { - echo "activate.sh: set BRIGHT_VISION_ROOT to the repo root, or run: source ./activate.sh from that directory" >&2 + echo "activate.sh: cd to the BrightVision repo root and run: source ./activate.sh" >&2 + echo " (BSH/zsh/bash; or export BRIGHT_VISION_ROOT=/path/to/BrightVision)" >&2 return 1 2>/dev/null || exit 1 } +export BRIGHT_VISION_ROOT="$ROOT" +export BV_ROOT="$ROOT" VENV="${ROOT}/.venv" die() { @@ -142,6 +176,15 @@ else return 1 fi + if [ -f "${ROOT}/brightdate-python/pyproject.toml" ]; then + if ! "$PYTHON" -m pip install -q -e "${ROOT}/brightdate-python"; then + die "editable install failed: brightdate at ${ROOT}/brightdate-python (git submodule update --init brightdate-python)" + return 1 + fi + else + echo "activate.sh: warning: brightdate-python missing — run: git submodule update --init brightdate-python" >&2 + fi + if [ ! -f "${ROOT}/pyproject.toml" ]; then die "missing ${ROOT}/pyproject.toml (bright_vision_core package)" return 1 diff --git a/apps/remote/App.tsx b/apps/remote/App.tsx index 44fb573..e337de4 100644 --- a/apps/remote/App.tsx +++ b/apps/remote/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from 'react' +import { RemoteChatPanel } from './RemoteChatPanel' import { ActivityIndicator, Button, @@ -23,6 +24,8 @@ export default function App() { const [qrPaste, setQrPaste] = useState('') const [health, setHealth] = useState(null) const [busy, setBusy] = useState(false) + const [tab, setTab] = useState<'connect' | 'chat'>('connect') + const [workspace, setWorkspace] = useState('') const client = useMemo( () => new CoreHttpClient(baseUrl.replace(/\/$/, ''), token.trim() || undefined), @@ -63,9 +66,20 @@ export default function App() { BrightVision Remote - R0: enter LAN URL from desktop Settings → BrightVision Remote (LAN Link), or paste QR - JSON. + LAN Link: Settings → BrightVision Remote. MVP: connect, chat, Stop, status line. + + + )} + + + + {recents.length > 0 && ( + <> + + Recent projects + + + {recents.map((path) => ( + onSelectedPathChange(path)} + data-testid="open-project-recent" + > + + + ))} + + + )} + + {suggestedPath && !recents.includes(suggestedPath) && ( + + )} + + + ) +} diff --git a/src/components/project/ProjectBar.tsx b/src/components/project/ProjectBar.tsx new file mode 100644 index 0000000..4d7edf0 --- /dev/null +++ b/src/components/project/ProjectBar.tsx @@ -0,0 +1,61 @@ +import FolderOpenIcon from '@mui/icons-material/FolderOpen' +import HubIcon from '@mui/icons-material/Hub' +import { Button, Chip, Stack, Tooltip, Typography } from '@mui/material' +import { projectDisplayName } from '../../ipc/openProject' + +interface ProjectBarProps { + projectPath: string + onOpenProject: () => void + disabled?: boolean + /** Shown when `.cecli.workspaces.yml` defines multiple projects (e.g. "3 repos"). */ + workspaceBadge?: string | null +} + +export function ProjectBar({ + projectPath, + onOpenProject, + disabled, + workspaceBadge, +}: ProjectBarProps) { + const label = projectDisplayName(projectPath) + const tip = workspaceBadge ? `${projectPath}\nCecli workspace: ${workspaceBadge}` : projectPath + return ( + + + + ) +} diff --git a/src/components/settings/AgentGuardSection.tsx b/src/components/settings/AgentGuardSection.tsx new file mode 100644 index 0000000..9c32156 --- /dev/null +++ b/src/components/settings/AgentGuardSection.tsx @@ -0,0 +1,104 @@ +import { + Alert, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from '@mui/material' +import type { AgentGuardPrefs, AgentTimeUnit } from '../../theme/agentGuardPrefs' +import { agentTimeUnitOptions, normalizeAgentTimeUnit } from '../../utils/agentGuard' +import { bdFromUnixMs, formatBdScalar } from '@brightvision/vision-client' + +interface AgentGuardSectionProps { + prefs: AgentGuardPrefs + brightDateMode: boolean + onChange: (prefs: AgentGuardPrefs) => void +} + +export function AgentGuardSection({ prefs, brightDateMode, onChange }: AgentGuardSectionProps) { + const units = agentTimeUnitOptions(brightDateMode) + const unit = normalizeAgentTimeUnit(prefs.maxAgentTimeUnit, brightDateMode) + const nowBd = formatBdScalar(bdFromUnixMs(Date.now()), 5) + + return ( + + + Agent limits & pause + + + Optional caps for /agent runs in this session. Leave fields empty for no + limit. Chat: /pause (finish current step, then hold),{' '} + /resume. Command allowlists need cecli support (longer-term). + + + + onChange({ ...prefs, maxAgentTurns: e.target.value.replace(/\D/g, '') })} + placeholder="No limit" + helperText="Count completed agent turns (each /agent run until done). Positive integer only." + inputProps={{ inputMode: 'numeric' }} + /> + + + onChange({ ...prefs, maxAgentTimeValue: e.target.value })} + placeholder="No limit" + helperText="Wall-clock time across agent turns in this session." + inputProps={{ inputMode: 'decimal' }} + /> + + Unit + + + + + onChange({ ...prefs, shutdownAt: e.target.value })} + placeholder={brightDateMode ? `After now (BD ${nowBd})` : 'No limit'} + helperText={ + brightDateMode + ? `BrightDate absolute BD (must be greater than now ≈ ${nowBd}).` + : 'Local date/time in the future; agent sends blocked after this moment.' + } + InputLabelProps={brightDateMode ? undefined : { shrink: true }} + /> + + + + Shell command allowlists (e.g. allow ls, partial yarn) are not + enforced in the UI yet — configure timeouts in cecli agent config; upstream allowlist is + tracked on the roadmap. + + + ) +} diff --git a/src/components/settings/CecliWorkspaceSection.tsx b/src/components/settings/CecliWorkspaceSection.tsx new file mode 100644 index 0000000..e560b44 --- /dev/null +++ b/src/components/settings/CecliWorkspaceSection.tsx @@ -0,0 +1,215 @@ +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import RefreshIcon from '@mui/icons-material/Refresh' +import SaveIcon from '@mui/icons-material/Save' +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Box, + Button, + Chip, + Link, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material' +import type { CecliWorkspaceInfo } from '../../ipc/httpClient' +import { isTauriRuntime } from '../../ipc/isTauri' +import { readWorkspaceTextFile, writeWorkspaceTextFile } from '../../ipc/workspaceEditor' + +const WORKSPACE_REL = '.cecli.workspaces.yml' + +interface CecliWorkspaceSectionProps { + workingDir: string + info: CecliWorkspaceInfo + loading: boolean + error: string | null + onRefresh: () => void | Promise + onOpenInEditor?: (relativePath: string) => void + onMessage?: (message: string, severity: 'info' | 'warning' | 'error') => void +} + +export function CecliWorkspaceSection({ + workingDir, + info, + loading, + error, + onRefresh, + onOpenInEditor, + onMessage, +}: CecliWorkspaceSectionProps) { + const [draft, setDraft] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (info.present && info.raw != null) { + setDraft(info.raw) + setDirty(false) + } else if (!info.present) { + setDraft( + [ + 'name: my-workspace', + 'projects:', + ' - name: app', + ` path: ${workingDir || '/abs/path/to/primary'}`, + ' primary: true', + ' - name: lib', + ' path: /abs/path/to/other-repo', + '', + ].join('\n') + ) + setDirty(false) + } + }, [info.present, info.raw, workingDir]) + + const loadFromDisk = useCallback(async () => { + if (!isTauriRuntime() || !workingDir.trim()) return + try { + const text = await readWorkspaceTextFile(workingDir, WORKSPACE_REL) + setDraft(text) + setDirty(false) + } catch { + /* file may not exist yet */ + } + }, [workingDir]) + + useEffect(() => { + if (isTauriRuntime() && info.present) void loadFromDisk() + }, [info.present, loadFromDisk]) + + const handleSave = async () => { + if (!isTauriRuntime()) { + onMessage?.('Save requires the desktop app', 'warning') + return + } + if (!workingDir.trim()) return + setSaving(true) + try { + await writeWorkspaceTextFile(workingDir, WORKSPACE_REL, draft) + setDirty(false) + onMessage?.('Saved .cecli.workspaces.yml — Stop & Start the session to apply.', 'info') + await onRefresh() + } catch (e) { + onMessage?.(e instanceof Error ? e.message : String(e), 'error') + } finally { + setSaving(false) + } + } + + return ( + + + + + Multi-repo workspace + + + + {info.present && onOpenInEditor && ( + + )} + + + + + Optional .cecli.workspaces.yml at the project root unions multiple git repos + for /add, repomap, and commits (cecli workspace mode). Example:{' '} + + docs/.cecli.workspaces.example.yml + + . Without this file, nested submodules still work via the superproject. + + + {error && ( + + {error} + + )} + + {info.parse_error && ( + + YAML parse/validate: {info.parse_error} + + )} + + {info.present ? ( + + + {info.name && } + + {info.projects.map((p) => ( + + ))} + + ) : ( + + No workspace file in this project. Edit below and save to create one (desktop), or copy + the example into your repo root. + + )} + + { + setDraft(e.target.value) + setDirty(true) + }} + disabled={!isTauriRuntime() && !info.present} + slotProps={{ + input: { + sx: { fontFamily: 'monospace', fontSize: '0.8rem' }, + }, + }} + helperText={ + isTauriRuntime() + ? 'Save writes the file under the open project. Restart the agent session after changes.' + : 'Editing requires the desktop app; web can view when the Vision API is running.' + } + /> + + {isTauriRuntime() && ( + + + + )} + + + ) +} diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 16b8cd8..fda4b0b 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -21,7 +21,6 @@ import { type OllamaModelsSnapshot, resolveLocalLlmForConfig, } from '../../ipc/localLlm' -import { WorkspaceBar } from '../WorkspaceBar' import type { AppearanceConfig } from '../../theme/appearance' import { AppearanceSection } from './AppearanceSection' import { ThinkingTimingSection } from './ThinkingTimingSection' @@ -46,12 +45,16 @@ import { SessionPersistenceSection } from './SessionPersistenceSection' import { NtfyAlertsSection } from './NtfyAlertsSection' import { MobileRemoteSection } from './MobileRemoteSection' import { AgentsSection } from './AgentsSection' +import { AgentGuardSection } from './AgentGuardSection' +import type { AgentGuardPrefs } from '../../theme/agentGuardPrefs' import type { AppVersions } from '../../hooks/useAppVersions' import type { SubAgentInfo } from '../../ipc/agentCommands' import { SessionModeToggle, type SessionMode, } from '../session/SessionModeToggle' +import type { CecliWorkspaceInfo } from '../../ipc/httpClient' +import { CecliWorkspaceSection } from './CecliWorkspaceSection' interface SettingsPanelProps { config: VisionConfig @@ -76,6 +79,8 @@ interface SettingsPanelProps { onEditorLanguagePrefsChange: (prefs: EditorLanguagePrefs) => void modelRouterPrefs: ModelRouterPrefs onModelRouterPrefsChange: (prefs: ModelRouterPrefs) => void + agentGuardPrefs: AgentGuardPrefs + onAgentGuardPrefsChange: (prefs: AgentGuardPrefs) => void sessionModel: string onSessionModeChange: (mode: SessionMode) => void liveSessionMode?: SessionMode | null @@ -87,6 +92,11 @@ interface SettingsPanelProps { sessionActive: boolean sessionId?: string | null onExportSessionDebug?: () => void | Promise + cecliWorkspace?: CecliWorkspaceInfo + cecliWorkspaceLoading?: boolean + cecliWorkspaceError?: string | null + onCecliWorkspaceRefresh?: () => void | Promise + onOpenWorkspaceFileInEditor?: (relativePath: string) => void } export function SettingsPanel({ @@ -112,6 +122,8 @@ export function SettingsPanel({ onEditorLanguagePrefsChange, modelRouterPrefs, onModelRouterPrefsChange, + agentGuardPrefs, + onAgentGuardPrefsChange, sessionModel, onSessionModeChange, liveSessionMode = null, @@ -123,6 +135,11 @@ export function SettingsPanel({ sessionActive, sessionId, onExportSessionDebug, + cecliWorkspace, + cecliWorkspaceLoading, + cecliWorkspaceError, + onCecliWorkspaceRefresh, + onOpenWorkspaceFileInEditor, }: SettingsPanelProps) { const [bundledEnginePath, setBundledEnginePath] = useState('') const [localLlmSnap, setLocalLlmSnap] = useState(null) @@ -166,10 +183,22 @@ export function SettingsPanel({ Model & system - Choose a project for git edits. Cecli + Vision API are bundled with - the app — you only set the project path, not a separate engine install per repo. + Open or switch the active project from the header folder control (not here). + Cecli + Vision API are bundled with the app — no per-repo engine install. + {cecliWorkspace != null && onCecliWorkspaceRefresh && ( + + )} + - - - Project (git repository) - - onChange({ ...config, workingDir })} - /> - + + The git project you edit is chosen when {DISPLAY_VISION} opens (or via the project name in + the header), not here. Model, API, and session options below apply to whichever project is + open. + + + - {label} — avg {formatDurationMs(dist.mean)}, median{' '} - {formatDurationMs(dist.median)}, p90 {formatDurationMs(dist.p90)}, min{' '} - {formatDurationMs(dist.min)}, max {formatDurationMs(dist.max)} ({dist.count} samples) + {label} — avg {formatDurationMs(dist.mean, fmtOpts)}, median{' '} + {formatDurationMs(dist.median, fmtOpts)}, p90 {formatDurationMs(dist.p90, fmtOpts)}, min{' '} + {formatDurationMs(dist.min, fmtOpts)}, max {formatDurationMs(dist.max, fmtOpts)} ({dist.count}{' '} + samples) ) } @@ -117,6 +121,7 @@ export function ThinkingStatsPanel({ onCsvError, onCsvSuccess, }: ThinkingStatsPanelProps) { + const fmtOpts = { brightDate: timingPrefs.brightDateMode } const models = useMemo(() => listModelsInHistory(store), [store]) const [filter, setFilter] = useState<'all' | 'current'>('current') const [csvBusy, setCsvBusy] = useState(false) @@ -292,8 +297,8 @@ export function ThinkingStatsPanel({ - - + + {filter === 'all' && view.byModel.length > 1 && ( @@ -348,9 +353,9 @@ export function ThinkingStatsPanel({ {m.model} {m.turns} - {formatDurationMs(m.response.mean)} + {formatDurationMs(m.response.mean, fmtOpts)} {formatOutputTps(avgTps)} - {formatDurationMs(m.think.mean)} + {formatDurationMs(m.think.mean, fmtOpts)} {formatThinkSharePct(m.avgThinkShare)} ) @@ -388,6 +393,7 @@ export function ThinkingStatsPanel({ {resourceColLabel('CPU')} {resourceColLabel('RAM')} {resourceColLabel('GPU')} + Mem pressure )} Prompt @@ -412,11 +418,11 @@ export function ThinkingStatsPanel({ > {formatModelLabel(row.model)} - {formatDurationMs(row.responseMs)} + {formatDurationMs(row.responseMs, fmtOpts)} {formatOutputTps(computeOutputTps(row.tokensReceived, row.responseMs))} - {formatDurationMs(row.thinkMs)} + {formatDurationMs(row.thinkMs, fmtOpts)} {formatThinkSharePct(thinkShare(row))} {isTauriRuntime() && ( <> @@ -443,6 +449,12 @@ export function ThinkingStatsPanel({ > {formatResourcePct(row.avgGpuPct, row.peakGpuPct, resourceMode)} + + {row.memPressurePeak == null + ? '—' + : (['normal', 'warn', 'critical'] as const)[row.memPressurePeak] ?? + String(row.memPressurePeak)} + )} {row.promptChars.toLocaleString()} diff --git a/src/components/settings/ThinkingTimingSection.tsx b/src/components/settings/ThinkingTimingSection.tsx index 808fe4f..17b59bf 100644 --- a/src/components/settings/ThinkingTimingSection.tsx +++ b/src/components/settings/ThinkingTimingSection.tsx @@ -50,6 +50,15 @@ export function ThinkingTimingSection({ Thinking / Reasoning sections. History and statistics are stored locally per model. + onChange({ ...prefs, brightDateMode: v })} + /> + } + label="BrightDate mode (BD / millidays for response time, ETA, and history)" + /> void /** Bumped after generate/refine saves layers — refreshes spec index panel. */ specIndexRefreshToken?: number + /** Open project path — tasks load from this repo's `.cecli/todos.json`. */ + projectPath?: string + /** Active chat session uses a different repo than the open project. */ + sessionWorkspaceMismatch?: boolean } export function TodoPanel({ @@ -175,6 +179,8 @@ export function TodoPanel({ specIndexRefreshToken, currentBranch, tauriLocal, + projectPath, + sessionWorkspaceMismatch, }: TodoPanelProps) { const importInputRef = useRef(null) const importMergeRef = useRef(false) @@ -561,8 +567,24 @@ export function TodoPanel({ )} + {sessionWorkspaceMismatch ? ( + + Chat is still using a different repository than the open project. Use Stop & Start in Chat, + or click the project name in the header to switch folders. + + ) : null} - Stored in .cecli/todos.json; three-layer specs also sync to{' '} + Stored in .cecli/todos.json + {projectPath ? ( + <> + {' '} + under{' '} + + {projectPath} + + + ) : null} + ; three-layer specs also sync to{' '} .cecli/specs/<id>/. {tauriLocal && !httpReady ? ` Desktop: tasks saved locally via Tauri (${DISPLAY_VISION_API} optional).` diff --git a/src/hooks/useAgentGuard.ts b/src/hooks/useAgentGuard.ts new file mode 100644 index 0000000..23b6331 --- /dev/null +++ b/src/hooks/useAgentGuard.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AgentGuardPrefs } from '../theme/agentGuardPrefs' +import { + agentLimitMessage, + checkAgentLimits, + formatAgentTurnsChip, + parsePositiveInt, + type AgentLimitBlockReason, +} from '../utils/agentGuard' + +export type AgentPauseState = 'running' | 'paused' | 'pause_after_turn' + +export interface AgentGuardSnapshot { + completedTurns: number + agentPhaseMs: number + pauseState: AgentPauseState + turnsChip: string + blockReason: AgentLimitBlockReason + maxTurns: number | null +} + +export function useAgentGuard( + prefs: AgentGuardPrefs, + brightDate: boolean, + isRunning: boolean, + isBusy: boolean, + agentTurnActive: boolean +) { + const [completedTurns, setCompletedTurns] = useState(0) + const [accumulatedMs, setAccumulatedMs] = useState(0) + const [pauseState, setPauseState] = useState('running') + const [tick, setTick] = useState(0) + const turnStartRef = useRef(null) + + const reset = useCallback(() => { + setCompletedTurns(0) + setAccumulatedMs(0) + setPauseState('running') + turnStartRef.current = null + }, []) + + useEffect(() => { + if (!isRunning) reset() + }, [isRunning, reset]) + + const beginAgentPhase = useCallback(() => { + if (turnStartRef.current == null) turnStartRef.current = Date.now() + }, []) + + const endAgentPhase = useCallback(() => { + const start = turnStartRef.current + if (start != null) { + setAccumulatedMs((ms) => ms + Math.max(0, Date.now() - start)) + turnStartRef.current = null + } + setCompletedTurns((n) => n + 1) + setPauseState((p) => (p === 'pause_after_turn' ? 'paused' : p)) + }, []) + + const pause = useCallback( + (afterCurrent = true) => { + if (afterCurrent && isBusy) setPauseState('pause_after_turn') + else setPauseState('paused') + }, + [isBusy] + ) + + const resume = useCallback(() => { + setPauseState('running') + }, []) + + useEffect(() => { + if (!isBusy || turnStartRef.current == null) return + const id = window.setInterval(() => setTick((t) => t + 1), 1000) + return () => window.clearInterval(id) + }, [isBusy]) + + const livePhaseMs = useMemo(() => { + void tick + const start = turnStartRef.current + const running = start != null ? Math.max(0, Date.now() - start) : 0 + return accumulatedMs + running + }, [accumulatedMs, tick]) + + const maxTurns = parsePositiveInt(prefs.maxAgentTurns) + + const limitReason = useMemo( + () => + checkAgentLimits({ + prefs, + brightDate, + completedAgentTurns: completedTurns, + agentPhaseMs: livePhaseMs, + }), + [prefs, brightDate, completedTurns, livePhaseMs] + ) + + const blockReason: AgentLimitBlockReason = + pauseState === 'paused' + ? 'paused' + : pauseState === 'pause_after_turn' && isBusy + ? null + : limitReason + + const snapshot: AgentGuardSnapshot = { + completedTurns, + agentPhaseMs: livePhaseMs, + pauseState, + turnsChip: formatAgentTurnsChip(completedTurns, maxTurns), + blockReason: pauseState === 'paused' ? 'paused' : limitReason, + maxTurns, + } + + const shouldBlockSend = + pauseState === 'paused' || (limitReason != null && !isBusy) + + const shouldInterrupt = + isBusy && + agentTurnActive && + (limitReason === 'max_time' || limitReason === 'shutdown') + + return { + snapshot, + reset, + beginAgentPhase, + endAgentPhase, + pause, + resume, + blockReason, + blockMessage: blockReason ? agentLimitMessage(blockReason) : '', + shouldBlockSend, + shouldInterrupt, + } +} diff --git a/src/hooks/useCecliWorkspace.ts b/src/hooks/useCecliWorkspace.ts new file mode 100644 index 0000000..a78d350 --- /dev/null +++ b/src/hooks/useCecliWorkspace.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react' +import type { CecliWorkspaceInfo } from '../ipc/httpClient' +import { createCoreHttpClient } from '../ipc/httpClient' + +const EMPTY: CecliWorkspaceInfo = { + present: false, + project_count: 0, + projects: [], +} + +export function useCecliWorkspace( + workingDir: string, + coreApiUrl: string, + coreApiToken?: string +) { + const [info, setInfo] = useState(EMPTY) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + const dir = workingDir?.trim() + if (!dir) { + setInfo(EMPTY) + setError(null) + return + } + setLoading(true) + setError(null) + try { + const client = createCoreHttpClient(coreApiUrl, coreApiToken || undefined) + const next = await client.getCecliWorkspace(dir) + setInfo(next) + } catch (e) { + setInfo(EMPTY) + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [workingDir, coreApiUrl, coreApiToken]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const multiRepoLabel = + info.present && info.project_count > 1 + ? `${info.project_count} repos` + : info.present && info.project_count === 1 + ? 'workspace' + : null + + return { info, loading, error, refresh, multiRepoLabel } +} diff --git a/src/hooks/useOpenProject.ts b/src/hooks/useOpenProject.ts new file mode 100644 index 0000000..149742a --- /dev/null +++ b/src/hooks/useOpenProject.ts @@ -0,0 +1,116 @@ +import { invoke } from '@tauri-apps/api/core' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + loadCurrentProject, + loadRecentProjects, + projectDisplayName, + recordRecentProject, + saveCurrentProject, + shouldSkipProjectGate, +} from '../ipc/openProject' +import { isTauriRuntime } from '../ipc/isTauri' +import { normalizeWorkspacePath } from '../utils/workspacePath' + +export interface UseOpenProjectOptions { + /** Fallback when nothing stored yet (legacy config). */ + fallbackPath: string + onProjectOpened: (path: string) => void + isSessionActive: boolean + stopSession?: () => Promise +} + +export function useOpenProject({ + fallbackPath, + onProjectOpened, + isSessionActive, + stopSession, +}: UseOpenProjectOptions) { + const skipGate = shouldSkipProjectGate() + const autoCommittedRef = useRef(false) + const [gateOpen, setGateOpen] = useState(!skipGate) + const [recents, setRecents] = useState(() => loadRecentProjects()) + const [selectedPath, setSelectedPath] = useState( + () => loadCurrentProject() || normalizeWorkspacePath(fallbackPath) + ) + const [suggestedPath, setSuggestedPath] = useState(null) + const [opening, setOpening] = useState(false) + + useEffect(() => { + if (!isTauriRuntime()) return + void invoke('detect_workspace', { + hint: loadCurrentProject() || fallbackPath || null, + }) + .then((dir) => { + const normalized = normalizeWorkspacePath(dir) + setSuggestedPath(normalized) + if (!loadCurrentProject()) setSelectedPath(normalized) + }) + .catch(() => {}) + }, [fallbackPath]) + + const commitOpen = useCallback( + async (path: string) => { + const normalized = normalizeWorkspacePath(path.trim()) + if (!normalized || normalized === '.') return + setOpening(true) + try { + if (isSessionActive && stopSession) { + await stopSession() + } + saveCurrentProject(normalized) + setRecents(recordRecentProject(normalized)) + onProjectOpened(normalized) + setGateOpen(false) + } finally { + setOpening(false) + } + }, + [isSessionActive, onProjectOpened, stopSession] + ) + + useEffect(() => { + if (!skipGate || autoCommittedRef.current) return + const path = loadCurrentProject() || normalizeWorkspacePath(fallbackPath) + if (!path || path === '.') return + autoCommittedRef.current = true + onProjectOpened(path) + setGateOpen(false) + }, [skipGate, fallbackPath, onProjectOpened]) + + const pickFolder = useCallback(async (): Promise => { + if (!isTauriRuntime()) return null + try { + const selected = await invoke('pick_workspace_folder') + if (!selected) return null + const normalized = normalizeWorkspacePath(selected) + setSelectedPath(normalized) + return normalized + } catch { + return null + } + }, []) + + const showProjectPicker = useCallback(() => { + const current = loadCurrentProject() + if (current) setSelectedPath(current) + setRecents(loadRecentProjects()) + setGateOpen(true) + }, []) + + const currentProject = gateOpen ? null : loadCurrentProject() || normalizeWorkspacePath(fallbackPath) + + return { + gateOpen, + selectedPath, + setSelectedPath, + recents, + suggestedPath, + opening, + commitOpen, + pickFolder, + showProjectPicker, + currentProject, + projectLabel: currentProject ? projectDisplayName(currentProject) : '', + skipGate, + } +} diff --git a/src/hooks/useSessionStallWatch.ts b/src/hooks/useSessionStallWatch.ts index 6b7af90..1aef6c1 100644 --- a/src/hooks/useSessionStallWatch.ts +++ b/src/hooks/useSessionStallWatch.ts @@ -16,10 +16,12 @@ export interface SessionStallWatch { export function useSessionStallWatch( isBusy: boolean, queuedCount: number, - sessionModel = '' + sessionModel = '', + hintOpts?: { isAgentTurn?: boolean; brightDate?: boolean } ): SessionStallWatch { const lastEventAtRef = useRef(null) const lastTokenAtRef = useRef(null) + const lastToolAtRef = useRef(null) const lastProgressDetailRef = useRef('') const [tick, setTick] = useState(0) @@ -27,10 +29,14 @@ export function useSessionStallWatch( const now = Date.now() lastEventAtRef.current = now if (type === 'token') lastTokenAtRef.current = now + if (type === 'tool_output' || type === 'tool_error' || type === 'tool_warning') { + lastToolAtRef.current = now + } if (type === 'progress' && detail) lastProgressDetailRef.current = detail if (type === 'done' || type === 'error') { lastEventAtRef.current = null lastTokenAtRef.current = null + lastToolAtRef.current = null lastProgressDetailRef.current = '' } } @@ -39,6 +45,7 @@ export function useSessionStallWatch( if (!isBusy) { lastEventAtRef.current = null lastTokenAtRef.current = null + lastToolAtRef.current = null lastProgressDetailRef.current = '' return } @@ -52,10 +59,12 @@ export function useSessionStallWatch( isBusy, lastEventAtRef.current, lastTokenAtRef.current, - lastProgressDetailRef.current + lastProgressDetailRef.current, + Date.now(), + lastToolAtRef.current ) const stalled = isLikelyStalled(activity) - const hint = turnActivityHint(activity, queuedCount, sessionModel) + const hint = turnActivityHint(activity, queuedCount, sessionModel, hintOpts) return { activity, hint, stalled, touchEvent } } diff --git a/src/hooks/useThinkingTiming.ts b/src/hooks/useThinkingTiming.ts index d0875ce..d6e960d 100644 --- a/src/hooks/useThinkingTiming.ts +++ b/src/hooks/useThinkingTiming.ts @@ -102,7 +102,13 @@ export function useThinkingTiming(model: string, prefs: ThinkingTimingPrefs) { ( timing: TurnThinkingTiming, resources?: TurnResourceStats, - tokens?: { tokensSent: number; tokensReceived: number } + tokens?: { tokensSent: number; tokensReceived: number }, + extras?: { + startBd?: number + endBd?: number + memPressurePeak?: number + captureMode?: string + } ): TurnTimingRecord | null => { if (timing.turnDurationMs <= 0) return null let recorded: TurnTimingRecord | null = null @@ -128,6 +134,12 @@ export function useThinkingTiming(model: string, prefs: ThinkingTimingPrefs) { resourceSampleCount: resources.sampleCount, } : {}), + ...(extras?.startBd != null ? { startBd: extras.startBd } : {}), + ...(extras?.endBd != null ? { endBd: extras.endBd } : {}), + ...(extras?.memPressurePeak != null + ? { memPressurePeak: extras.memPressurePeak } + : {}), + ...(extras?.captureMode ? { captureMode: extras.captureMode } : {}), }) recorded = next.history[next.history.length - 1] ?? null saveThinkingStats(next) @@ -149,6 +161,11 @@ export function useThinkingTiming(model: string, prefs: ThinkingTimingPrefs) { return () => window.clearInterval(id) }, [prefs.showLiveTimer, publishLive]) + const formatDuration = useCallback( + (ms: number) => formatDurationMs(ms, { brightDate: prefs.brightDateMode }), + [prefs.brightDateMode] + ) + return { live: prefs.showLiveTimer ? live : null, beginTurn, @@ -159,7 +176,8 @@ export function useThinkingTiming(model: string, prefs: ThinkingTimingPrefs) { statsView, refreshStats, statsStore, - formatDuration: formatDurationMs, + formatDuration, + brightDateMode: prefs.brightDateMode, peekActiveKind: (content: string) => getActiveAssistantSection(content), } } diff --git a/src/hooks/useVisionApiControls.ts b/src/hooks/useVisionApiControls.ts index 1c36bc4..a2bd549 100644 --- a/src/hooks/useVisionApiControls.ts +++ b/src/hooks/useVisionApiControls.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import type { VisionConfig } from '../ipc/config' -import { CoreHttpClient } from '../ipc/httpClient' +import { createCoreHttpClient } from '../ipc/httpClient' import { waitForVisionApi } from '../ipc/health' import { isTauriRuntime } from '../ipc/isTauri' import { @@ -35,7 +35,7 @@ export function useVisionApiControls( return } try { - const client = new CoreHttpClient(baseUrl, config.coreApiToken || undefined) + const client = createCoreHttpClient(baseUrl, config.coreApiToken || undefined) await client.health() setApiReachable(true) } catch { @@ -59,7 +59,7 @@ export function useVisionApiControls( onLogLines?.([`[vision-api] Spawning on :${VISION_API_DEFAULT_PORT}…`]) const url = await spawnDesktopVisionApi(config) onApiUrl?.(url) - const client = new CoreHttpClient(url, config.coreApiToken || undefined) + const client = createCoreHttpClient(url, config.coreApiToken || undefined) await waitForVisionApi(client) setApiReachable(true) onLogLines?.([`[vision-api] OK ${url}`]) diff --git a/src/hooks/useVisionSession.ts b/src/hooks/useVisionSession.ts index c920cd9..bdf4360 100644 --- a/src/hooks/useVisionSession.ts +++ b/src/hooks/useVisionSession.ts @@ -12,6 +12,7 @@ import type { CoreEventBase } from '../ipc/events' import { isTauriRuntime } from '../ipc/isTauri' import { SseIdleTimeoutError } from '../ipc/sseIdle' import { createVisionApiSession, type VisionApiSession } from '../ipc/visionApi' +import { isUserCancellationError } from '../utils/abort' import { parseAddCommandPath } from '../utils/suggestedFiles' import { useProcess } from '../progress/processStore' @@ -187,6 +188,10 @@ export function useVisionSession( try { await sessionRef.current.send(content, todoOptions) } catch (err) { + if (isUserCancellationError(err)) { + process.idle() + return + } const message = err instanceof SseIdleTimeoutError ? err.message diff --git a/src/hooks/useWorkspaceTodos.ts b/src/hooks/useWorkspaceTodos.ts index a19e81d..63bdc84 100644 --- a/src/hooks/useWorkspaceTodos.ts +++ b/src/hooks/useWorkspaceTodos.ts @@ -7,6 +7,7 @@ import { exportTodoStore, importTodoStore } from '../todos/markdown' import { applyLayerTemplate, applyTodoTemplate } from '../todos/templates' import type { EarsLintResult, SpecIndexResult, TraceabilityResult } from '../todos/earsTypes' import type { ChecklistItem, TodoItem, TodoStore, TodoStatus } from '../todos/types' +import { workspacePathsEqual } from '../utils/workspacePath' function nowIso(): string { return new Date().toISOString() @@ -20,6 +21,8 @@ export interface WorkspaceTodosApi { client: CoreHttpClient workspace: string sessionId?: string | null + /** Session workspace from createSession; used to sync agent todo.txt when it matches `workspace`. */ + sessionWorkspace?: string | null } type TodoPatch = Partial< @@ -67,6 +70,11 @@ export function useWorkspaceTodos( const reloadGenerationRef = useRef(0) const tauriLocal = isTauriRuntime() + useEffect(() => { + setStore(null) + setHttpReady(false) + }, [workingDir]) + const exportSpecToDisk = useCallback( async (id: string) => { if (httpReady && api) { @@ -113,8 +121,20 @@ export function useWorkspaceTodos( if (api?.client) { try { await api.client.health() - // Agent todo.txt sync runs on session create (cecli), not on every list — - // re-import here resurrected tasks after delete. + if (stale()) return + const sessionId = api.sessionId + const sessionWs = api.sessionWorkspace + if ( + sessionId && + sessionWs && + workspacePathsEqual(sessionWs, api.workspace) + ) { + try { + await api.client.importSessionAgentTodoPlan(sessionId) + } catch { + /* best-effort — list still returns on-disk store */ + } + } if (stale()) return const data = await api.client.listWorkspaceTodos(api.workspace) if (stale()) return diff --git a/src/ipc/config.ts b/src/ipc/config.ts index ec2d03c..169c149 100644 --- a/src/ipc/config.ts +++ b/src/ipc/config.ts @@ -23,7 +23,9 @@ export interface VisionConfig { /** Desktop: built-in Local LLM (Ollama + preload) before Vision session. */ manageLocalLlm: boolean extraParams: string - /** Git project the agent edits (any repo; does not need bright-vision-core inside it). */ + /** + * Git project the agent edits. Set via **Open project** (launch gate / header), mirrored here for API/session. + */ workingDir: string /** Auto-answer up to N confirmations per session (0 = always prompt). */ autoApproveLimit: number @@ -74,7 +76,7 @@ export const DEFAULT_CONFIG: VisionConfig = { autoStageOnDone: true, coreEnginePath: CORE_ENGINE_DIR, pythonPath: '', - coreApiUrl: 'http://127.0.0.1:8741', + coreApiUrl: 'http://localhost:8741', coreApiToken: '', contextFiles: [], sessionEncrypt: false, diff --git a/src/ipc/createCoreHttpClient.ts b/src/ipc/createCoreHttpClient.ts new file mode 100644 index 0000000..147cc4d --- /dev/null +++ b/src/ipc/createCoreHttpClient.ts @@ -0,0 +1,12 @@ +import { CoreHttpClient } from '@brightvision/vision-client' +import { patchCoreHttpClientForTauri } from './desktopHttpClientPatch' +import { isTauriRuntime } from './isTauri' + +/** Vision HTTP client; on desktop, mutating requests use Tauri/reqwest instead of WebKit fetch. */ +export function createCoreHttpClient(baseUrl: string, token?: string): CoreHttpClient { + const client = new CoreHttpClient(baseUrl, token) + if (isTauriRuntime()) { + patchCoreHttpClientForTauri(client, token) + } + return client +} diff --git a/src/ipc/desktopHttpClientPatch.ts b/src/ipc/desktopHttpClientPatch.ts new file mode 100644 index 0000000..0ac8349 --- /dev/null +++ b/src/ipc/desktopHttpClientPatch.ts @@ -0,0 +1,341 @@ +/** + * Route mutating Vision API calls through Tauri/reqwest on desktop (WebKit fetch POST often fails). + */ +import type { + CoreHttpClient, + EarsLintResult, + PatchTodoResult, + TodoItem, + TodoStore, + TraceabilityResult, +} from '@brightvision/vision-client' +import { normalizeStore, normalizeTodo } from '@brightvision/vision-client' +import { + desktopVisionFetchBlob, + desktopVisionFetchRaw, + desktopVisionRequest, +} from './desktopVisionApi' + +function workspaceQs(workspace: string): string { + return `workspace=${encodeURIComponent(workspace)}` +} + +function mapSpecJobResult(data: { + requirements?: string + design?: string + tasks_md?: string + raw?: string + item?: TodoItem | null + ears_blocked?: boolean +}) { + return { + requirements: data.requirements ?? '', + design: data.design ?? '', + tasks_md: data.tasks_md ?? '', + raw: data.raw ?? '', + item: data.item ? normalizeTodo(data.item) : null, + ears_blocked: Boolean(data.ears_blocked), + } +} + +function specGenPollMaxAttempts(): number { + const meta = + typeof import.meta !== 'undefined' + ? (import.meta as ImportMeta & { env?: { VITE_LLM_SPEC_GEN_TIMEOUT_S?: string } }).env + ?.VITE_LLM_SPEC_GEN_TIMEOUT_S + : undefined + const raw = meta || '1200' + const sec = Number(raw) + const cap = Number.isFinite(sec) && sec > 0 ? sec : 1200 + return Math.max(90, Math.ceil(cap * 1.05)) +} + +export function patchCoreHttpClientForTauri( + client: CoreHttpClient, + token?: string +): CoreHttpClient { + const base = client.baseUrl + + const req = (method: string, path: string, body?: unknown) => + desktopVisionRequest(method, base, path, token, body) + + client.undo = (sessionId) => + req('POST', `sessions/${sessionId}/undo`) + + client.deleteSession = async (sessionId) => { + await req('DELETE', `sessions/${sessionId}`) + } + + client.addSessionFiles = (sessionId, paths) => + req('POST', `sessions/${sessionId}/files`, { paths }) + + client.uploadSessionFiles = (sessionId, files) => + req('POST', `sessions/${sessionId}/files/upload`, { files }) + + client.submitConfirm = (sessionId, confirmId, answer) => + req('POST', `sessions/${sessionId}/confirm`, { + confirm_id: confirmId, + answer, + }).then(() => undefined) + + client.importAgentTodoPlan = async (workspace) => { + const path = `workspaces/todos/import-agent-plan?${workspaceQs(workspace)}` + const { status, body } = await desktopVisionFetchRaw('POST', base, path, token) + if (status === 404) return null + if (status < 200 || status >= 300) { + throw new Error(`import agent todo plan: ${status}`) + } + return normalizeStore(body) + } + + client.importSessionAgentTodoPlan = (sessionId) => + req('POST', `sessions/${encodeURIComponent(sessionId)}/todos/import-agent-plan`).then( + normalizeStore + ) + + client.createWorkspaceTodo = (workspace, body) => + req('POST', `workspaces/todos?${workspaceQs(workspace)}`, body) + + client.patchWorkspaceTodo = async (workspace, todoId, body) => { + const data = await req( + 'PATCH', + `workspaces/todos/${todoId}?${workspaceQs(workspace)}`, + body + ) + return { + item: normalizeTodo(data.item), + auto_completed: Boolean(data.auto_completed), + ears_requirements_ok: data.ears_requirements_ok ?? null, + ears_error_count: data.ears_error_count ?? null, + } + } + + client.deleteWorkspaceTodo = (workspace, todoId) => + req('DELETE', `workspaces/todos/${todoId}?${workspaceQs(workspace)}`).then(() => undefined) + + client.syncWorkspaceSpecFiles = (workspace, todoId) => + req( + 'POST', + `workspaces/todos/${todoId}/sync-spec-files?${workspaceQs(workspace)}` + ).then(normalizeTodo) + + client.exportWorkspaceSpecFiles = (workspace, todoId) => + req( + 'POST', + `workspaces/todos/${todoId}/export-spec-files?${workspaceQs(workspace)}` + ).then(normalizeTodo) + + client.lintWorkspaceRequirements = (workspace, todoId, draft) => + req( + 'POST', + `workspaces/todos/${todoId}/lint-requirements?${workspaceQs(workspace)}`, + draft?.requirements != null ? { requirements: draft.requirements } : {} + ) + + client.lintSessionRequirements = (sessionId, todoId, draft) => + req( + 'POST', + `sessions/${sessionId}/todos/${todoId}/lint-requirements`, + draft?.requirements != null ? { requirements: draft.requirements } : {} + ) + + client.repairWorkspaceSpecFolders = (workspace) => + req<{ created_count: number; created_ids: string[] }>( + 'POST', + `workspaces/todos/repair-spec-folders?${workspaceQs(workspace)}` + ) + + client.pruneOrphanWorkspaceSpecFolders = (workspace) => + req<{ removed_count: number; removed_ids: string[] }>( + 'POST', + `workspaces/todos/prune-orphan-spec-folders?${workspaceQs(workspace)}` + ) + + client.traceWorkspaceSpec = (workspace, todoId, draft) => { + const body: Record = {} + if (draft?.requirements != null) body.requirements = draft.requirements + if (draft?.design != null) body.design = draft.design + if (draft?.tasks_md != null) body.tasks_md = draft.tasks_md + return req( + 'POST', + `workspaces/todos/${todoId}/trace-spec?${workspaceQs(workspace)}`, + body + ) + } + + client.traceSessionSpec = (sessionId, todoId, draft) => { + const body: Record = {} + if (draft?.requirements != null) body.requirements = draft.requirements + if (draft?.design != null) body.design = draft.design + if (draft?.tasks_md != null) body.tasks_md = draft.tasks_md + return req( + 'POST', + `sessions/${sessionId}/todos/${todoId}/trace-spec`, + body + ) + } + + client.moveWorkspaceTodo = (workspace, todoId, direction) => + req('POST', `workspaces/todos/${todoId}/move?${workspaceQs(workspace)}`, { + direction, + }).then(normalizeStore) + + client.setActiveWorkspaceTodo = (workspace, activeId) => + req('PUT', `workspaces/todos/active?${workspaceQs(workspace)}`, { activeId }).then( + normalizeStore + ) + + client.importWorkspaceTodos = (workspace, markdown, merge) => + req('POST', 'workspaces/todos/import', { workspace, markdown, merge }).then( + normalizeStore + ) + + client.createTodo = (sessionId, body) => + req('POST', `sessions/${sessionId}/todos`, body) + + client.patchTodo = async (sessionId, todoId, body) => { + const data = await req('PATCH', `sessions/${sessionId}/todos/${todoId}`, body) + return { + item: normalizeTodo(data.item), + auto_completed: Boolean(data.auto_completed), + ears_requirements_ok: data.ears_requirements_ok ?? null, + ears_error_count: data.ears_error_count ?? null, + } + } + + client.deleteTodo = (sessionId, todoId) => + req('DELETE', `sessions/${sessionId}/todos/${todoId}`).then(() => undefined) + + client.setActiveTodo = (sessionId, activeId) => + req('PUT', `sessions/${sessionId}/todos/active`, { activeId }).then(normalizeStore) + + client.interruptTurn = (sessionId) => + req('POST', `sessions/${sessionId}/interrupt`).then(() => undefined) + + client.fetchSessionDebugBlob = (sessionId) => + desktopVisionFetchBlob( + 'GET', + base, + `sessions/${sessionId}/debug`, + token, + 'application/json' + ) + + const pollSpecGenerateJob = async ( + jobId: string, + signal?: AbortSignal + ): Promise<{ + status: string + error?: string | null + requirements: string + design: string + tasks_md: string + raw: string + item: TodoItem | null + ears_blocked?: boolean + }> => { + const path = `workspaces/todos/generate-spec/${jobId}` + const maxAttempts = specGenPollMaxAttempts() + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const data = await req<{ + status: string + error?: string | null + requirements?: string + design?: string + tasks_md?: string + raw?: string + item?: TodoItem | null + ears_blocked?: boolean + }>('GET', path) + if (data.status === 'completed') { + return { status: data.status, ...mapSpecJobResult(data) } + } + if (data.status === 'error') { + throw new Error(data.error || 'Spec generation failed') + } + await new Promise((r) => setTimeout(r, 1000)) + } + throw new Error( + `Spec generation timed out after ${maxAttempts}s (set VITE_LLM_SPEC_GEN_TIMEOUT_S / LLM_SPEC_GEN_TIMEOUT_S)` + ) + } + + client.generateWorkspaceTodoSpec = async ( + workspace, + sessionId, + todoId, + body, + signal + ) => { + const qs = `${workspaceQs(workspace)}&session_id=${encodeURIComponent(sessionId)}` + const payload = { + prompt: body.prompt, + mode: body.mode ?? 'generate', + section: body.section ?? 'all', + context_paths: body.context_paths ?? [], + apply: body.apply ?? true, + enforce_ears: body.enforce_ears ?? true, + background: body.background ?? true, + } + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const { status, body: resBody } = await desktopVisionFetchRaw( + 'POST', + base, + `workspaces/todos/${todoId}/generate-spec?${qs}`, + token, + payload + ) + if (status === 202) { + const started = resBody as { job_id: string } + const done = await pollSpecGenerateJob(started.job_id, signal) + return { + requirements: done.requirements, + design: done.design, + tasks_md: done.tasks_md, + raw: done.raw, + item: done.item, + ears_blocked: done.ears_blocked, + } + } + if (status < 200 || status >= 300) { + throw new Error(`generate spec: ${status}`) + } + return mapSpecJobResult(resBody as Record) + } + + client.generateSessionTodoSpec = async (sessionId, todoId, body, signal) => { + const payload = { + prompt: body.prompt, + mode: body.mode ?? 'generate', + apply: body.apply ?? true, + background: body.background ?? true, + } + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const { status, body: resBody } = await desktopVisionFetchRaw( + 'POST', + base, + `sessions/${sessionId}/todos/${todoId}/generate-spec`, + token, + payload + ) + if (status === 202) { + const started = resBody as { job_id: string } + const done = await pollSpecGenerateJob(started.job_id, signal) + return { + requirements: done.requirements, + design: done.design, + tasks_md: done.tasks_md, + raw: done.raw, + item: done.item, + ears_blocked: done.ears_blocked, + } + } + if (status < 200 || status >= 300) { + throw new Error(`generate spec: ${status}`) + } + return mapSpecJobResult(resBody as Record) + } + + return client +} diff --git a/src/ipc/desktopVisionApi.ts b/src/ipc/desktopVisionApi.ts new file mode 100644 index 0000000..64be485 --- /dev/null +++ b/src/ipc/desktopVisionApi.ts @@ -0,0 +1,86 @@ +/** + * Desktop Vision HTTP via Tauri/reqwest (WebKit fetch to localhost often fails with "Load failed"). + */ +import { invoke } from '@tauri-apps/api/core' +import { isTauriRuntime } from './isTauri' + +export interface VisionApiFetchResult { + status: number + body: unknown +} + +export async function desktopVisionFetchRaw( + method: string, + baseUrl: string, + path: string, + bearerToken: string | undefined, + body?: unknown +): Promise { + if (!isTauriRuntime()) { + throw new Error('desktopVisionFetchRaw is only available in the desktop app') + } + return invoke('vision_api_fetch', { + method: method.toUpperCase(), + baseUrl, + path: path.replace(/^\//, ''), + bearerToken: bearerToken?.trim() || null, + body: body === undefined ? null : body, + }) +} + +export async function desktopVisionRequest( + method: string, + baseUrl: string, + path: string, + bearerToken: string | undefined, + body?: unknown +): Promise { + const { status, body: resBody } = await desktopVisionFetchRaw( + method, + baseUrl, + path, + bearerToken, + body + ) + if (status < 200 || status >= 300) { + const detail = + typeof resBody === 'string' ? resBody : JSON.stringify(resBody) + throw new Error(`${method} /${path.replace(/^\//, '')}: ${status} ${detail}`) + } + return resBody as T +} + +export async function desktopVisionPost( + baseUrl: string, + apiPath: string, + bearerToken: string | undefined, + body: unknown +): Promise { + return desktopVisionRequest('POST', baseUrl, apiPath, bearerToken, body) +} + +export async function desktopVisionFetchBlob( + method: string, + baseUrl: string, + path: string, + bearerToken: string | undefined, + mime = 'application/octet-stream' +): Promise { + const res = await invoke<{ + status: number + body_base64: string + content_type: string | null + }>('vision_api_fetch_bytes', { + method: method.toUpperCase(), + baseUrl, + path: path.replace(/^\//, ''), + bearerToken: bearerToken?.trim() || null, + }) + if (res.status < 200 || res.status >= 300) { + throw new Error(`${method} /${path.replace(/^\//, '')}: ${res.status}`) + } + const binary = atob(res.body_base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: res.content_type ?? mime }) +} diff --git a/src/ipc/desktopVisionSse.ts b/src/ipc/desktopVisionSse.ts new file mode 100644 index 0000000..990054f --- /dev/null +++ b/src/ipc/desktopVisionSse.ts @@ -0,0 +1,104 @@ +/** + * Desktop Vision API message turns (SSE) via Tauri Channel + reqwest. + * WebKit `fetch` POST to localhost often fails with "Load failed". + */ +import { Channel, invoke } from '@tauri-apps/api/core' +import type { CoreEventBase } from './events' +import type { SendMessageOptions } from './httpClient' +import { + SSE_IDLE_MS_AFTER_EVENT, + SSE_IDLE_MS_BEFORE_FIRST, + SseIdleTimeoutError, + sseEventResetsIdleTimer, +} from './httpClient' + +export async function* desktopVisionSendMessage( + baseUrl: string, + sessionId: string, + bearerToken: string | undefined, + content: string, + options: SendMessageOptions | undefined, + signal?: AbortSignal +): AsyncGenerator { + const channel = new Channel() + const queue: CoreEventBase[] = [] + let streamDone = false + let streamError: Error | null = null + let notify: (() => void) | null = null + + const wake = () => { + const n = notify + notify = null + n?.() + } + + channel.onmessage = (event) => { + queue.push(event) + wake() + } + + const body = { + content, + preproc: options?.preproc ?? true, + active_todo_id: options?.activeTodoId ?? null, + inject_todo_spec: options?.injectTodoSpec ?? false, + spec_focus: options?.specFocus ?? false, + force_tier: options?.forceTier ?? null, + escalate_from_last: options?.escalateFromLast ?? false, + } + + const invokeDone = invoke('send_vision_message', { + onEvent: channel, + baseUrl, + sessionId, + bearerToken: bearerToken ?? null, + body, + }) + .then(() => { + streamDone = true + wake() + }) + .catch((err: unknown) => { + streamError = err instanceof Error ? err : new Error(String(err)) + streamDone = true + wake() + }) + + const onAbort = () => { + void invoke('cancel_vision_message') + } + signal?.addEventListener('abort', onAbort) + + let streamActivity = false + try { + while (true) { + if (queue.length === 0 && !streamDone) { + const phase = streamActivity ? 'after_events' : 'before_first_event' + await Promise.race([ + new Promise((resolve) => { + notify = resolve + }), + new Promise((_, reject) => { + setTimeout( + () => reject(new SseIdleTimeoutError(phase)), + streamActivity ? SSE_IDLE_MS_AFTER_EVENT : SSE_IDLE_MS_BEFORE_FIRST + ) + }), + ]) + } + + while (queue.length > 0) { + const event = queue.shift()! + if (sseEventResetsIdleTimer(event)) streamActivity = true + yield event + } + + if (streamError) throw streamError + if (streamDone) break + if (signal?.aborted) break + } + } finally { + signal?.removeEventListener('abort', onAbort) + await invokeDone.catch(() => {}) + } +} diff --git a/src/ipc/httpClient.ts b/src/ipc/httpClient.ts index 6b6d973..9c58832 100644 --- a/src/ipc/httpClient.ts +++ b/src/ipc/httpClient.ts @@ -1,2 +1,3 @@ /** Re-export Vision HTTP client (canonical: @brightvision/vision-client). */ export * from '@brightvision/vision-client' +export { createCoreHttpClient } from './createCoreHttpClient' diff --git a/src/ipc/openProject.test.ts b/src/ipc/openProject.test.ts new file mode 100644 index 0000000..1a0d94c --- /dev/null +++ b/src/ipc/openProject.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CURRENT_PROJECT_KEY, + loadCurrentProject, + loadRecentProjects, + projectDisplayName, + recordRecentProject, + saveCurrentProject, +} from './openProject' + +function mockLocalStorage() { + const store = new Map() + vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + clear: () => { + store.clear() + }, + }) +} + +describe('openProject storage', () => { + beforeEach(() => { + mockLocalStorage() + }) + + it('saves and loads current project', () => { + saveCurrentProject('/Volumes/Code/foo/') + expect(loadCurrentProject()).toBe('/Volumes/Code/foo') + expect(localStorage.getItem(CURRENT_PROJECT_KEY)).toBe('/Volumes/Code/foo') + }) + + it('records recents without duplicates', () => { + recordRecentProject('/a') + recordRecentProject('/b') + recordRecentProject('/a') + expect(loadRecentProjects()).toEqual(['/a', '/b']) + }) + + it('display name uses last path segment', () => { + expect(projectDisplayName('/Volumes/Code/brightdate-rust')).toBe('brightdate-rust') + }) +}) diff --git a/src/ipc/openProject.ts b/src/ipc/openProject.ts new file mode 100644 index 0000000..d694b2c --- /dev/null +++ b/src/ipc/openProject.ts @@ -0,0 +1,67 @@ +/** Open-project persistence (separate from model/API settings). */ + +import { normalizeWorkspacePath } from '../utils/workspacePath' + +export const CURRENT_PROJECT_KEY = 'vision-current-project' +export const RECENT_PROJECTS_KEY = 'vision-recent-projects' +/** Automation / Playwright: skip the launch gate and use stored project. */ +export const PROJECT_GATE_SKIP_KEY = 'vision-skip-project-gate' + +export const MAX_RECENT_PROJECTS = 10 + +export function loadCurrentProject(): string | null { + try { + const raw = localStorage.getItem(CURRENT_PROJECT_KEY)?.trim() + return raw ? normalizeWorkspacePath(raw) : null + } catch { + return null + } +} + +export function saveCurrentProject(path: string): void { + const normalized = normalizeWorkspacePath(path) + localStorage.setItem(CURRENT_PROJECT_KEY, normalized) +} + +export function loadRecentProjects(): string[] { + try { + const raw = localStorage.getItem(RECENT_PROJECTS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + const seen = new Set() + const out: string[] = [] + for (const item of parsed) { + if (typeof item !== 'string' || !item.trim()) continue + const p = normalizeWorkspacePath(item) + if (seen.has(p)) continue + seen.add(p) + out.push(p) + } + return out.slice(0, MAX_RECENT_PROJECTS) + } catch { + return [] + } +} + +export function recordRecentProject(path: string): string[] { + const normalized = normalizeWorkspacePath(path) + const prev = loadRecentProjects().filter((p) => p !== normalized) + const next = [normalized, ...prev].slice(0, MAX_RECENT_PROJECTS) + localStorage.setItem(RECENT_PROJECTS_KEY, JSON.stringify(next)) + return next +} + +export function projectDisplayName(path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '') + const parts = normalized.split('/').filter(Boolean) + return parts.length ? parts[parts.length - 1]! : normalized || '.' +} + +export function shouldSkipProjectGate(): boolean { + try { + return localStorage.getItem(PROJECT_GATE_SKIP_KEY) === '1' + } catch { + return false + } +} diff --git a/src/ipc/visionApi.ts b/src/ipc/visionApi.ts index 9d9e56c..683e021 100644 --- a/src/ipc/visionApi.ts +++ b/src/ipc/visionApi.ts @@ -6,11 +6,30 @@ import { invoke } from '@tauri-apps/api/core' import type { VisionConfig } from './config' import type { CoreEventBase } from './events' import type { CoreSessionInfo, ModelRouterApiConfig, SendMessageOptions } from './httpClient' -import { CoreHttpClient } from './httpClient' +import { createCoreHttpClient } from './httpClient' +import type { CoreHttpClient } from './httpClient' import { waitForVisionApi } from './health' import { isTauriRuntime } from './isTauri' +import { desktopVisionPost } from './desktopVisionApi' +import { desktopVisionSendMessage } from './desktopVisionSse' import { spawnDesktopVisionApi } from './visionApiSpawn' import type { ProcessUpdate } from '../progress/types' +import { isUserCancellationError } from '../utils/abort' + +async function visionStartError(err: unknown): Promise { + const base = err instanceof Error ? err : new Error(String(err)) + if (!isTauriRuntime()) return base + try { + const lines = await invoke('drain_core_api_logs') + if (lines.length) { + const tail = lines.slice(-10).join('\n') + return new Error(`${base.message}\n\nEngine log:\n${tail}`) + } + } catch { + /* best-effort */ + } + return base +} export type CoreEventHandler = (event: CoreEventBase) => void export type ProcessPhaseHandler = (update: ProcessUpdate) => void @@ -45,6 +64,7 @@ export function createVisionApiSession( let sessionId: string | null = null let sessionInfo: CoreSessionInfo | null = null let apiUrl: string | null = null + let apiToken: string | undefined let desktopStartedServe = false let sendAbort: AbortController | null = null let startAbort: AbortController | null = null @@ -98,7 +118,8 @@ export function createVisionApiSession( } if (signal.aborted) throw new DOMException('Start cancelled', 'AbortError') apiUrl = url - client = new CoreHttpClient(url, cfg.coreApiToken || undefined) + apiToken = cfg.coreApiToken?.trim() || undefined + client = createCoreHttpClient(url, apiToken) onPhase?.({ phase: 'connecting', label: 'Connecting', detail: url, progress: 0.45 }) await waitForVisionApi(client, signal) if (signal.aborted) throw new DOMException('Start cancelled', 'AbortError') @@ -108,7 +129,7 @@ export function createVisionApiSession( detail: cfg.workingDir, progress: 0.75, }) - const session = await client.createSession({ + const sessionBody = { workspace: cfg.workingDir, model: cfg.model, model_router: options?.modelRouter, @@ -121,13 +142,25 @@ export function createVisionApiSession( auto_save_session_name: cfg.autoSaveSessionName, chat_history_file: cfg.chatHistoryFile, session_mode: cfg.sessionMode, - }) + } + const session = isTauriRuntime() + ? await invoke('create_vision_session', { + baseUrl: url, + bearerToken: cfg.coreApiToken?.trim() || null, + body: { + stream: true, + dirty_commits: true, + dry_run: false, + ...sessionBody, + }, + }) + : await client.createSession(sessionBody) sessionId = session.session_id sessionInfo = session return session } catch (err) { await teardownPartialStart() - throw err + throw await visionStartError(err) } finally { startAbort = null } @@ -161,29 +194,48 @@ export function createVisionApiSession( desktopStartedServe = false } apiUrl = null + apiToken = undefined }, async send(content, options) { - if (!client || !sessionId) { + if (!client || !sessionId || !apiUrl) { throw new Error('Vision API session is not started') } sendAbort?.abort() sendAbort = new AbortController() const signal = sendAbort.signal try { - for await (const event of client.sendMessage(sessionId, content, signal, options)) { + const stream = isTauriRuntime() + ? desktopVisionSendMessage( + apiUrl, + sessionId, + apiToken, + content, + options, + signal + ) + : client.sendMessage(sessionId, content, signal, options) + for await (const event of stream) { onEvent(event) } + } catch (err) { + if (isUserCancellationError(err)) return + throw err } finally { sendAbort = null } }, async addFiles(paths) { - if (!client || !sessionId) { + if (!client || !sessionId || !apiUrl) { throw new Error('Vision API session is not started') } - const result = await client.addSessionFiles(sessionId, paths) + const result = isTauriRuntime() + ? await desktopVisionPost<{ + files_in_chat: string[] + events: CoreEventBase[] + }>(apiUrl, `sessions/${sessionId}/files`, apiToken, { paths }) + : await client.addSessionFiles(sessionId, paths) sessionInfo = { session_id: sessionId, workspace: sessionInfo?.workspace ?? '', @@ -197,10 +249,15 @@ export function createVisionApiSession( }, async uploadFiles(files) { - if (!client || !sessionId) { + if (!client || !sessionId || !apiUrl) { throw new Error('Vision API session is not started') } - const result = await client.uploadSessionFiles(sessionId, files) + const result = isTauriRuntime() + ? await desktopVisionPost<{ + files_in_chat: string[] + events: CoreEventBase[] + }>(apiUrl, `sessions/${sessionId}/files/upload`, apiToken, { files }) + : await client.uploadSessionFiles(sessionId, files) sessionInfo = { session_id: sessionId, workspace: sessionInfo?.workspace ?? '', @@ -217,24 +274,45 @@ export function createVisionApiSession( sendAbort?.abort() sendAbort = null const sid = sessionId - const c = client - if (sid && c) { - void c.interruptTurn(sid).catch(() => {}) + if (!sid || !apiUrl) return + if (isTauriRuntime()) { + void invoke('cancel_vision_message') + void desktopVisionPost( + apiUrl, + `sessions/${sid}/interrupt`, + apiToken, + {} + ).catch(() => {}) + } else { + void client?.interruptTurn(sid).catch(() => {}) } }, async submitConfirm(confirmId, answer) { - if (!client || !sessionId) { + if (!client || !sessionId || !apiUrl) { throw new Error('Vision API session is not started') } - await client.submitConfirm(sessionId, confirmId, answer) + if (isTauriRuntime()) { + await desktopVisionPost(apiUrl, `sessions/${sessionId}/confirm`, apiToken, { + confirm_id: confirmId, + answer, + }) + } else { + await client.submitConfirm(sessionId, confirmId, answer) + } }, async undo() { - if (!client || !sessionId) { + if (!client || !sessionId || !apiUrl) { throw new Error('Vision API session is not started') } - const result = await client.undo(sessionId) + const result = isTauriRuntime() + ? await desktopVisionPost<{ + events: CoreEventBase[] + commits: unknown + last_commit_hash: string | null + }>(apiUrl, `sessions/${sessionId}/undo`, apiToken, {}) + : await client.undo(sessionId) for (const event of result.events) { onEvent(event as CoreEventBase) } diff --git a/src/ipc/visionApiSpawn.test.ts b/src/ipc/visionApiSpawn.test.ts index c1c60da..f40363a 100644 --- a/src/ipc/visionApiSpawn.test.ts +++ b/src/ipc/visionApiSpawn.test.ts @@ -4,14 +4,14 @@ import { visionApiBaseUrl } from './visionApiSpawn' describe('visionApiBaseUrl', () => { it('uses config URL when set', () => { - expect(visionApiBaseUrl({ ...DEFAULT_CONFIG, coreApiUrl: 'http://127.0.0.1:8741/' })).toBe( - 'http://127.0.0.1:8741' + expect(visionApiBaseUrl({ ...DEFAULT_CONFIG, coreApiUrl: 'http://localhost:8741/' })).toBe( + 'http://localhost:8741' ) }) it('defaults to :8741', () => { expect(visionApiBaseUrl({ ...DEFAULT_CONFIG, coreApiUrl: '' })).toBe( - 'http://127.0.0.1:8741' + 'http://localhost:8741' ) }) }) diff --git a/src/ipc/visionApiSpawn.ts b/src/ipc/visionApiSpawn.ts index bcb3d3b..8c860c3 100644 --- a/src/ipc/visionApiSpawn.ts +++ b/src/ipc/visionApiSpawn.ts @@ -1,13 +1,19 @@ import type { VisionConfig } from './config' +import { invoke } from '@tauri-apps/api/core' import { invokeWithTimeout } from './tauriInvoke' import { isTauriRuntime } from './isTauri' +interface EngineInstallInfo { + install_root: string + default_python_path: string +} + export const VISION_API_DEFAULT_PORT = 8741 export function visionApiBaseUrl(config: VisionConfig): string { const trimmed = config.coreApiUrl?.trim() if (trimmed) return trimmed.replace(/\/$/, '') - return `http://127.0.0.1:${VISION_API_DEFAULT_PORT}` + return `http://localhost:${VISION_API_DEFAULT_PORT}` } /** Spawn `bright-vision-core-serve` via Tauri (idempotent if already running). */ @@ -18,11 +24,16 @@ export async function spawnDesktopVisionApi(cfg: VisionConfig): Promise if (cfg.sessionEncrypt) { await invokeWithTimeout('ensure_session_encryption_key', {}) } + const coreEnginePath = + cfg.coreEnginePath.trim() === '.' || !cfg.coreEnginePath.trim() + ? (await invoke('engine_install_info')).install_root + : cfg.coreEnginePath + return invokeWithTimeout( 'start_core_api', { workingDir: cfg.workingDir, - coreEnginePath: cfg.coreEnginePath, + coreEnginePath, pythonPath: cfg.pythonPath, extraParams: cfg.extraParams, ollamaApiBase: cfg.ollamaApiBase, diff --git a/src/ipc/visionClientCommands.ts b/src/ipc/visionClientCommands.ts index 8d196c6..2f0775f 100644 --- a/src/ipc/visionClientCommands.ts +++ b/src/ipc/visionClientCommands.ts @@ -1,6 +1,6 @@ /** Slash commands handled in the shell (not sent to the Vision API / Cecli turn). */ -export type VisionClientCommandId = 'ps' | 'tags' | 'models' +export type VisionClientCommandId = 'ps' | 'tags' | 'models' | 'turns' | 'pause' | 'resume' export interface VisionClientCommand { name: string @@ -16,6 +16,21 @@ export const VISION_CLIENT_COMMANDS: VisionClientCommand[] = [ summary: 'Ollama /api/tags + /api/ps (both tables)', id: 'models', }, + { + name: '/turns', + summary: 'Recent turn timings table (response, think, memory pressure)', + id: 'turns', + }, + { + name: '/pause', + summary: 'Pause agent after the current step (blocks new sends until /resume)', + id: 'pause', + }, + { + name: '/resume', + summary: 'Resume agent after /pause', + id: 'resume', + }, ] const CLIENT_BY_NAME = new Map( diff --git a/src/progress/processStore.tsx b/src/progress/processStore.tsx index 96ad7c2..d5c3cd1 100644 --- a/src/progress/processStore.tsx +++ b/src/progress/processStore.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from 'react' import type { CoreEventBase, CoreProgressEvent } from '../ipc/events' +import { isBenignTurnStopError } from '../utils/abort' import { isWaitingForModelProgress, progressEventToUpdate, @@ -145,17 +146,19 @@ export function ProcessProvider({ children }: { children: ReactNode }) { break case 'tool_output': case 'tool_error': - case 'tool_warning': + case 'tool_warning': { + const toolText = String((ev as { text?: string }).text ?? '').trim() dispatch({ type: 'apply', update: { phase: 'tool', label: PHASE_LABELS.tool, - detail: String(ev.type).replace('tool_', ''), + detail: toolText.slice(0, 120) || String(ev.type).replace('tool_', ''), progress: null, }, }) break + } case 'confirm': { const auto = Boolean((ev as { auto_answered?: boolean }).auto_answered) if (!auto) { @@ -188,14 +191,20 @@ export function ProcessProvider({ children }: { children: ReactNode }) { dispatch({ type: 'idle' }) } break - case 'error': + case 'error': { streamedTokensThisTurnRef.current = false + const message = String(ev.text ?? 'Unknown error') + if (isBenignTurnStopError(message)) { + dispatch({ type: 'idle' }) + break + } dispatch({ type: 'fail', - message: String(ev.text ?? 'Unknown error'), + message, }) window.setTimeout(() => dispatch({ type: 'idle' }), 4000) break + } default: break } diff --git a/src/storageKeys.ts b/src/storageKeys.ts index c360b70..e2a1412 100644 --- a/src/storageKeys.ts +++ b/src/storageKeys.ts @@ -14,6 +14,7 @@ export const EDITOR_LANGUAGE_PREFS_STORAGE_KEY = `${PRODUCT_VISION}-editor-langu export const MODEL_ROUTER_PREFS_STORAGE_KEY = `${PRODUCT_VISION}-model-router` export const NTFY_ALERTS_STORAGE_KEY = `${PRODUCT_VISION}-ntfy-alerts` export const SPEC_FOCUS_STORAGE_KEY = `${PRODUCT_VISION}-spec-focus` +export const AGENT_GUARD_STORAGE_KEY = `${PRODUCT_VISION}-agent-guard` /** Read key; returns null when unset. */ export function readStorageItem(currentKey: string): string | null { diff --git a/src/theme/agentGuardPrefs.ts b/src/theme/agentGuardPrefs.ts new file mode 100644 index 0000000..ecee80e --- /dev/null +++ b/src/theme/agentGuardPrefs.ts @@ -0,0 +1,37 @@ +import { AGENT_GUARD_STORAGE_KEY } from '../storageKeys' + +export { AGENT_GUARD_STORAGE_KEY } + +/** Duration unit for max agent time (subset depends on BrightDate mode in UI). */ +export type AgentTimeUnit = 'sec' | 'min' | 'hr' | 'md' | 'd' + +export interface AgentGuardPrefs { + /** Empty = no limit; positive integer string only. */ + maxAgentTurns: string + maxAgentTimeValue: string + maxAgentTimeUnit: AgentTimeUnit + /** Conventional: `datetime-local` value (local TZ). BrightDate: absolute BD scalar string. */ + shutdownAt: string +} + +export const DEFAULT_AGENT_GUARD_PREFS: AgentGuardPrefs = { + maxAgentTurns: '', + maxAgentTimeValue: '', + maxAgentTimeUnit: 'min', + shutdownAt: '', +} + +export function loadAgentGuardPrefs(): AgentGuardPrefs { + try { + const raw = localStorage.getItem(AGENT_GUARD_STORAGE_KEY) + if (!raw) return { ...DEFAULT_AGENT_GUARD_PREFS } + const parsed = JSON.parse(raw) as Partial + return { ...DEFAULT_AGENT_GUARD_PREFS, ...parsed } + } catch { + return { ...DEFAULT_AGENT_GUARD_PREFS } + } +} + +export function saveAgentGuardPrefs(prefs: AgentGuardPrefs): void { + localStorage.setItem(AGENT_GUARD_STORAGE_KEY, JSON.stringify(prefs)) +} diff --git a/src/theme/thinkingTimingPrefs.ts b/src/theme/thinkingTimingPrefs.ts index 5de8a38..499d616 100644 --- a/src/theme/thinkingTimingPrefs.ts +++ b/src/theme/thinkingTimingPrefs.ts @@ -9,6 +9,11 @@ export interface ThinkingTimingPrefs { showSectionDurations: boolean showMessageTurnTotal: boolean showStatsInSettings: boolean + /** + * Show durations, ETA, and history timestamps in BrightDate (BD / millidays). + * See [brightdate.org](https://brightdate.org). + */ + brightDateMode: boolean /** CPU/RAM/GPU columns in timing history (desktop). */ resourceDisplay: TimingResourceDisplay /** Workspace-relative or absolute path; empty = CSV file export disabled. */ @@ -22,6 +27,7 @@ export const DEFAULT_THINKING_TIMING_PREFS: ThinkingTimingPrefs = { showSectionDurations: true, showMessageTurnTotal: true, showStatsInSettings: true, + brightDateMode: false, resourceDisplay: 'avgPeak', timingStatsCsvPath: '.cecli/timing-history.csv', timingStatsAutoAppendCsv: false, diff --git a/src/todos/formatContext.ts b/src/todos/formatContext.ts index e731a27..3f91dfe 100644 --- a/src/todos/formatContext.ts +++ b/src/todos/formatContext.ts @@ -58,22 +58,82 @@ export function formatTodoContext(todo: TodoItem, allTodos?: TodoItem[]): string return lines.join('\n') } +export function todoHasSpecLayers(todo: TodoItem): boolean { + const item = migrateTodoLayers(todo) + const placeholders = new Set([ + '(No requirements yet.)', + '(No design yet.)', + '(No implementation tasks yet.)', + ]) + for (const text of [item.requirements, item.design, item.spec]) { + const trimmed = text.trim() + if (trimmed && !placeholders.has(trimmed)) return true + } + return false +} + +export function formatTodoContextLight(todo: TodoItem, allTodos?: TodoItem[]): string { + const item = migrateTodoLayers(todo) + const lines = [`[Active task: ${item.title} · id ${item.id.slice(0, 8)}]`, ''] + if (item.branch.trim()) { + lines.push(`**Git branch:** ${item.branch.trim()}`) + } + if (item.pr_url.trim()) { + lines.push(`**Pull request:** ${item.pr_url.trim()}`) + } + if (item.branch.trim() || item.pr_url.trim()) { + lines.push('') + } + + if (item.depends_on.length && allTodos?.length) { + const pending: string[] = [] + for (const depId of item.depends_on) { + const dep = allTodos.find((t) => t.id === depId || t.id.startsWith(depId)) + if (dep && dep.status !== 'done') { + pending.push(`${dep.title} (${dep.id.slice(0, 8)})`) + } + } + if (pending.length) { + lines.push(`**Blocked by:** ${pending.join(', ')}`, '') + } + } + + if (item.checklist?.length) { + lines.push('## Checklist') + for (const entry of item.checklist) { + lines.push(`- [${entry.done ? 'x' : ' '}] ${entry.text}`) + } + } else if (item.tasks_md.trim()) { + lines.push('## Tasks', item.tasks_md.trim()) + } + lines.push('', '---', '') + return lines.join('\n') +} + export function buildStartWorkMessage(todo: TodoItem, allTodos: TodoItem[]): string { const item = migrateTodoLayers(todo) const blocked = item.depends_on.some((depId) => { const dep = allTodos.find((t) => t.id === depId || t.id.startsWith(depId)) return dep && dep.status !== 'done' }) - if (blocked) { + if (todoHasSpecLayers(item)) { + if (blocked) { + return ( + 'Implement the active task per the injected requirements, design, and implementation tasks. ' + + 'Resolve or acknowledge blocking dependencies first.' + ) + } return ( 'Implement the active task per the injected requirements, design, and implementation tasks. ' + - 'Resolve or acknowledge blocking dependencies first.' + 'Work through implementation tasks in order; update the checklist as you complete acceptance items.' ) } - return ( - 'Implement the active task per the injected requirements, design, and implementation tasks. ' + - 'Work through implementation tasks in order; update the checklist as you complete acceptance items.' - ) + if (blocked) { + return ( + 'Work the active task checklist in order. Resolve or acknowledge blocking dependencies first.' + ) + } + return 'Work the active task checklist in order. Mark items done as you complete them.' } export function buildImplementStepMessage(step: ImplementationStep, todo: TodoItem): string { diff --git a/src/utils/abort.test.ts b/src/utils/abort.test.ts index fcce9aa..6b5245e 100644 --- a/src/utils/abort.test.ts +++ b/src/utils/abort.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it } from 'vitest' -import { mergeAbortSignals } from './abort' +import { isBenignTurnStopError, isUserCancellationError, mergeAbortSignals } from './abort' + +describe('isUserCancellationError', () => { + it('detects AbortError', () => { + expect(isUserCancellationError(new DOMException('Aborted', 'AbortError'))).toBe(true) + expect(isUserCancellationError(new Error('The operation was aborted'))).toBe(true) + }) + + it('ignores real failures', () => { + expect(isUserCancellationError(new Error('message: 500'))).toBe(false) + }) +}) + +describe('isBenignTurnStopError', () => { + it('detects bgpucap shutdown timeout', () => { + expect( + isBenignTurnStopError( + "Command '['/opt/homebrew/bin/bgpucap', '-f', 'json']' timed out after 5 seconds" + ) + ).toBe(true) + }) +}) describe('mergeAbortSignals', () => { it('aborts when parent aborts', () => { diff --git a/src/utils/abort.ts b/src/utils/abort.ts index b92ff10..4071cf3 100644 --- a/src/utils/abort.ts +++ b/src/utils/abort.ts @@ -1,3 +1,25 @@ +/** True when the user hit Stop or the SSE fetch was aborted (not a product failure). */ +export function isUserCancellationError(err: unknown): boolean { + if (err instanceof DOMException && err.name === 'AbortError') return true + if (err instanceof Error) { + if (err.name === 'AbortError') return true + const msg = err.message.toLowerCase() + if (msg.includes('abort') || msg.includes('cancel')) return true + } + return false +} + +/** Core ``error`` SSE text that should not flash the activity bar after Stop. */ +export function isBenignTurnStopError(text: string): boolean { + const t = text.trim() + if (!t) return false + if (/abort|cancel/i.test(t)) return true + if (/keyboardinterrupt|interrupted during/i.test(t)) return true + if (/broken\s*pipe/i.test(t)) return true + if (/bgpucap/i.test(t) && /timed out after/i.test(t)) return true + return false +} + /** Combine abort signals; aborts when any source aborts. */ export function mergeAbortSignals(...sources: (AbortSignal | undefined)[]): AbortSignal { const controller = new AbortController() diff --git a/src/utils/addFileMessages.test.ts b/src/utils/addFileMessages.test.ts index fb26903..8ab6c3c 100644 --- a/src/utils/addFileMessages.test.ts +++ b/src/utils/addFileMessages.test.ts @@ -21,6 +21,14 @@ describe('addFileMessages', () => { expect(rewriteAddFileToolMessage(raw)).toBe(raw) }) + it('rewrites not-on-disk add errors', () => { + const raw = + 'Not on disk: src/resolver.rs — create the file first or add an existing path to context.' + const out = rewriteAddFileToolMessage(raw) + expect(out).toContain('not on disk') + expect(out).toContain('design outline') + }) + it('formats not-added snackbar', () => { const msg = formatFilesNotAddedSnackbar( ['src/a.tsx', 'src/b.tsx'], diff --git a/src/utils/addFileMessages.ts b/src/utils/addFileMessages.ts index 4afa5f1..e9f0865 100644 --- a/src/utils/addFileMessages.ts +++ b/src/utils/addFileMessages.ts @@ -6,6 +6,9 @@ const LEGACY_GITIGNORE_ADD = /can't add\s+(.+?)\s+which is in gitignore\.?/i +const NOT_ON_DISK = /^Not on disk:\s+(.+?)\s+—/i +const LEGACY_NOT_A_FILE = /^Not a file:\s+(.+)$/i + /** Shorten long paths for snackbars and alerts. */ export function shortDisplayPath(path: string, maxLen = 48): string { const p = path.trim().replace(/\\/g, '/') @@ -17,6 +20,14 @@ export function shortDisplayPath(path: string, maxLen = 48): string { /** Rewrite legacy cecli “in gitignore” tool_error text for the chat timeline. */ export function rewriteAddFileToolMessage(text: string, workspace?: string): string { const trimmed = text.trim() + const notDisk = trimmed.match(NOT_ON_DISK) ?? trimmed.match(LEGACY_NOT_A_FILE) + if (notDisk) { + const file = shortDisplayPath(notDisk[1].trim(), 56) + return ( + `Could not add ${file} to context — not on disk yet. ` + + 'The assistant may have named a planned file in a design outline; create it or pick an existing path.' + ) + } const legacy = trimmed.match(LEGACY_GITIGNORE_ADD) if (!legacy) return text @@ -49,6 +60,6 @@ export function formatFilesNotAddedSnackbar(missing: string[], workspace?: strin const wsHint = ws ? ` Workspace: ${shortDisplayPath(ws, 56)}.` : '' return ( `Not in context: ${listed}.${wsHint} ` + - 'See tool messages above—ignore rules, wrong project folder, or path outside the repo.' + 'Path may be missing on disk, excluded by ignore rules, outside the repo, or the project folder may be wrong.' ) } diff --git a/src/utils/agentGuard.test.ts b/src/utils/agentGuard.test.ts new file mode 100644 index 0000000..f5ca72e --- /dev/null +++ b/src/utils/agentGuard.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + checkAgentLimits, + parseMaxAgentTimeMs, + parsePositiveInt, + parseShutdownDeadlineMs, +} from './agentGuard' +import { DEFAULT_AGENT_GUARD_PREFS } from '../theme/agentGuardPrefs' + +describe('agentGuard', () => { + it('parses conventional max time', () => { + expect(parseMaxAgentTimeMs('5', 'min', false)).toBe(300_000) + }) + + it('parses BrightDate max time in md', () => { + expect(parseMaxAgentTimeMs('10', 'md', true)).toBe(864_000) + }) + + it('blocks when turn cap reached', () => { + expect( + checkAgentLimits({ + prefs: { ...DEFAULT_AGENT_GUARD_PREFS, maxAgentTurns: '3' }, + brightDate: false, + completedAgentTurns: 3, + agentPhaseMs: 0, + }) + ).toBe('max_turns') + }) + + it('parses future datetime-local shutdown', () => { + const future = new Date(Date.now() + 3600_000).toISOString().slice(0, 16) + const ms = parseShutdownDeadlineMs(future, false) + expect(ms).not.toBeNull() + expect(ms!).toBeGreaterThan(Date.now()) + }) + + it('rejects empty max turns', () => { + expect(parsePositiveInt('')).toBeNull() + expect(parsePositiveInt('0')).toBeNull() + }) +}) diff --git a/src/utils/agentGuard.ts b/src/utils/agentGuard.ts new file mode 100644 index 0000000..488d1de --- /dev/null +++ b/src/utils/agentGuard.ts @@ -0,0 +1,139 @@ +import { bdFromUnixMs } from '@brightvision/vision-client' +import type { AgentGuardPrefs, AgentTimeUnit } from '../theme/agentGuardPrefs' + +const SECONDS_PER_MD = 86.4 +const SECONDS_PER_DAY = 86400 + +export function agentTimeUnitOptions(brightDate: boolean): { value: AgentTimeUnit; label: string }[] { + if (brightDate) { + return [ + { value: 'md', label: 'millidays (md)' }, + { value: 'd', label: 'days (d)' }, + ] + } + return [ + { value: 'sec', label: 'seconds' }, + { value: 'min', label: 'minutes' }, + { value: 'hr', label: 'hours' }, + ] +} + +export function normalizeAgentTimeUnit( + unit: AgentTimeUnit, + brightDate: boolean +): AgentTimeUnit { + const allowed = agentTimeUnitOptions(brightDate).map((o) => o.value) + if (allowed.includes(unit)) return unit + return brightDate ? 'md' : 'min' +} + +export function parsePositiveInt(raw: string): number | null { + const t = raw.trim() + if (!t) return null + const n = Number(t) + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) return null + return n +} + +export function parseMaxAgentTimeMs( + value: string, + unit: AgentTimeUnit, + brightDate: boolean +): number | null { + const t = value.trim() + if (!t) return null + const n = Number(t) + if (!Number.isFinite(n) || n <= 0) return null + const u = normalizeAgentTimeUnit(unit, brightDate) + switch (u) { + case 'sec': + return n * 1000 + case 'min': + return n * 60_000 + case 'hr': + return n * 3_600_000 + case 'md': + return n * SECONDS_PER_MD * 1000 + case 'd': + return n * SECONDS_PER_DAY * 1000 + default: + return null + } +} + +/** Parse shutdown deadline as unix ms, or null when unset/invalid. */ +export function parseShutdownDeadlineMs( + shutdownAt: string, + brightDate: boolean, + nowMs = Date.now() +): number | null { + const t = shutdownAt.trim() + if (!t) return null + if (brightDate) { + const bd = Number(t) + if (!Number.isFinite(bd)) return null + const nowBd = bdFromUnixMs(nowMs) + if (bd <= nowBd) return null + const deltaSec = (bd - nowBd) * SECONDS_PER_DAY + return nowMs + deltaSec * 1000 + } + const ms = Date.parse(t) + if (!Number.isFinite(ms) || ms <= nowMs) return null + return ms +} + +export type AgentLimitBlockReason = + | 'paused' + | 'max_turns' + | 'max_time' + | 'shutdown' + | null + +export interface AgentLimitCheckInput { + prefs: AgentGuardPrefs + brightDate: boolean + completedAgentTurns: number + agentPhaseMs: number + nowMs?: number +} + +export function checkAgentLimits(input: AgentLimitCheckInput): AgentLimitBlockReason { + const now = input.nowMs ?? Date.now() + const maxTurns = parsePositiveInt(input.prefs.maxAgentTurns) + if (maxTurns != null && input.completedAgentTurns >= maxTurns) { + return 'max_turns' + } + const maxMs = parseMaxAgentTimeMs( + input.prefs.maxAgentTimeValue, + input.prefs.maxAgentTimeUnit, + input.brightDate + ) + if (maxMs != null && input.agentPhaseMs >= maxMs) { + return 'max_time' + } + const shutdownMs = parseShutdownDeadlineMs(input.prefs.shutdownAt, input.brightDate, now) + if (shutdownMs != null && now >= shutdownMs) { + return 'shutdown' + } + return null +} + +export function agentLimitMessage(reason: AgentLimitBlockReason): string { + switch (reason) { + case 'paused': + return 'Agent is paused — send /resume to continue.' + case 'max_turns': + return 'Maximum agent turns reached (Settings → Agents).' + case 'max_time': + return 'Maximum agent time reached (Settings → Agents).' + case 'shutdown': + return 'Agent shutdown time reached (Settings → Agents).' + default: + return 'Agent cannot run.' + } +} + +export function formatAgentTurnsChip(completed: number, max: number | null): string { + if (max == null) return completed > 0 ? `Agent turns ${completed}` : '' + return `Agent ${completed}/${max}` +} diff --git a/src/utils/agentPlanEta.test.ts b/src/utils/agentPlanEta.test.ts new file mode 100644 index 0000000..a6d7ef7 --- /dev/null +++ b/src/utils/agentPlanEta.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { estimateAgentPlanEta, mergeTurnAndPlanEta } from './agentPlanEta' +import { estimateTurnEta } from './turnEtaEstimate' +import { recordTurnTiming, type ThinkingStatsStore } from './thinkingStats' + +describe('agentPlanEta', () => { + it('estimates from pending implementation steps', () => { + let store: ThinkingStatsStore = { version: 2, history: [] } + for (let i = 0; i < 4; i++) { + store = recordTurnTiming(store, 'ollama_chat/test', { + responseMs: 120_000, + thinkMs: 10_000, + promptChars: 100, + }) + } + const eta = estimateAgentPlanEta({ + tasksMd: `- [x] 1. First\n- [ ] 2. Second\n- [ ] 3. Third`, + model: 'ollama_chat/test', + statsStore: store, + }) + expect(eta?.remainingMs).toBeGreaterThan(0) + expect(eta?.shortLabel).toContain('plan') + }) + + it('merges with turn ETA using longer remaining', () => { + const turn = estimateTurnEta({ + model: 'm', + promptChars: 100, + elapsedMs: 1000, + statsStore: { version: 2, history: [] }, + }) + const plan = { + remainingMs: 600_000, + totalMs: 900_000, + shortLabel: '~plan', + tooltip: 'plan', + confidence: 'low' as const, + } + const merged = mergeTurnAndPlanEta(turn, plan, false) + expect(merged?.remainingMs).toBe(600_000) + }) +}) diff --git a/src/utils/agentPlanEta.ts b/src/utils/agentPlanEta.ts new file mode 100644 index 0000000..2f1c734 --- /dev/null +++ b/src/utils/agentPlanEta.ts @@ -0,0 +1,81 @@ +import { parseImplementationSteps } from '../todos/tasksMd' +import { + buildTimingStatsView, + type ThinkingStatsStore, +} from './thinkingStats' +import { formatDurationMs } from './thinkingTiming' +import type { TurnEtaEstimate } from './turnEtaEstimate' + +export interface AgentPlanEtaInput { + tasksMd: string + model: string + statsStore: ThinkingStatsStore + brightDate?: boolean +} + +/** + * When the agent has started a numbered task list, estimate remaining work from + * pending steps × (median turn time / typical steps per plan). + */ +export function estimateAgentPlanEta(input: AgentPlanEtaInput): TurnEtaEstimate | null { + const steps = parseImplementationSteps(input.tasksMd) + if (steps.length < 2) return null + const done = steps.filter((s) => s.done).length + const pending = steps.length - done + if (pending <= 0 || done <= 0) return null + + const view = buildTimingStatsView(input.statsStore, input.model.trim() || 'unknown') + if (view.response.count < 2) return null + + const fmt = (ms: number) => formatDurationMs(ms, { brightDate: input.brightDate }) + const perStep = view.response.median / Math.max(1, steps.length) + const remainingMs = Math.round(pending * perStep) + const totalMs = Math.round(steps.length * perStep) + + return { + remainingMs, + totalMs, + shortLabel: `~${fmt(remainingMs)} plan*`, + tooltip: [ + `Agent task plan: ${done}/${steps.length} steps done, ${pending} remaining.`, + `~${fmt(perStep)} per step (from ${view.response.count} turns, median ${fmt(view.response.median)}).`, + 'Blended with turn ETA when both are shown.', + ].join('\n'), + confidence: view.response.count >= 5 ? 'medium' : 'low', + } +} + +/** Prefer the longer remaining estimate; merge tooltips when both exist. */ +export function mergeTurnAndPlanEta( + turn: TurnEtaEstimate | null, + plan: TurnEtaEstimate | null, + brightDate?: boolean +): TurnEtaEstimate | null { + if (!turn && !plan) return null + if (!plan) return turn + if (!turn) return plan + + const turnRem = turn.remainingMs ?? 0 + const planRem = plan.remainingMs ?? 0 + const usePlan = planRem > turnRem && planRem > 0 + const primary = usePlan ? plan : turn + const secondary = usePlan ? turn : plan + + const remainingMs = Math.max(turnRem, planRem) || null + const fmt = (ms: number) => formatDurationMs(ms, { brightDate }) + const tooltip = [primary.tooltip, '---', secondary.tooltip].join('\n') + + return { + remainingMs, + totalMs: Math.max(turn.totalMs ?? 0, plan.totalMs ?? 0) || null, + shortLabel: + remainingMs != null && remainingMs > 0 ? `~${fmt(remainingMs)} left*` : primary.shortLabel, + tooltip, + confidence: + primary.confidence === 'high' || secondary.confidence === 'high' + ? 'high' + : primary.confidence === 'medium' || secondary.confidence === 'medium' + ? 'medium' + : 'low', + } +} diff --git a/src/utils/brightdateTiming.test.ts b/src/utils/brightdateTiming.test.ts new file mode 100644 index 0000000..c04350f --- /dev/null +++ b/src/utils/brightdateTiming.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + J2000_UNIX_MS, + bdAddSeconds, + bdFromUnixMs, + formatEtcBrightDate, +} from '@brightvision/vision-client' + +describe('brightdateTiming epoch', () => { + it('J2000 UTC label is BD 0 per BrightDate spec', () => { + expect(J2000_UNIX_MS).toBe(946_727_935_816) + expect(bdFromUnixMs(J2000_UNIX_MS)).toBeCloseTo(0, 9) + }) + + it('ETC adds duration in BD space (1 md ≈ +0.001 BD)', () => { + const base = 9648.68 + expect(bdAddSeconds(base, 86.4)).toBeCloseTo(9648.681, 3) + expect(formatEtcBrightDate(86.4, base)).toMatch(/BD 9648\.68/) + }) +}) diff --git a/src/utils/progressDisplay.test.ts b/src/utils/progressDisplay.test.ts new file mode 100644 index 0000000..1ec0bc3 --- /dev/null +++ b/src/utils/progressDisplay.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { buildActivityPresentation, formatProgressElapsedSuffix } from './progressDisplay' + +describe('formatProgressElapsedSuffix', () => { + it('reformats seconds suffix in conventional mode', () => { + expect(formatProgressElapsedSuffix('Running slash commands (1864s)', { brightDate: false })).toBe( + 'Running slash commands (31m 4s)' + ) + }) + + it('reformats seconds suffix in BrightDate mode', () => { + const out = formatProgressElapsedSuffix('Running slash commands (864s)', { brightDate: true }) + expect(out).toContain('md') + expect(out).not.toMatch(/\(864s\)/) + }) +}) + +describe('buildActivityPresentation', () => { + it('uses AGENT for slash preproc during agent turn', () => { + const p = buildActivityPresentation({ + processLabel: 'Vision', + processDetail: 'Running slash commands (120s)', + isAgentTurn: true, + brightDate: false, + }) + expect(p.brand).toBe('AGENT') + expect(p.headline).toBe('Running agent commands') + }) + + it('maps shell execution tool output', () => { + const p = buildActivityPresentation({ + processLabel: 'Running tools', + processDetail: 'output', + isAgentTurn: true, + lastToolSnippet: 'Executing shell command with 45s timeout.', + }) + expect(p.brand).toBe('AGENT') + expect(p.headline).toBe('Executing shell command') + }) +}) diff --git a/src/utils/progressDisplay.ts b/src/utils/progressDisplay.ts new file mode 100644 index 0000000..4244347 --- /dev/null +++ b/src/utils/progressDisplay.ts @@ -0,0 +1,132 @@ +import { formatDurationMs } from './thinkingTiming' + +/** Core SSE progress pulses append ``(123s)`` — reformat for display. */ +const PROGRESS_ELAPSED_SUFFIX_RE = /\s*\((\d+)s\)\s*$/ + +export function formatProgressElapsedSuffix( + text: string, + opts?: { brightDate?: boolean } +): string { + const m = text.match(PROGRESS_ELAPSED_SUFFIX_RE) + if (!m) return text + const sec = Number(m[1]) + if (!Number.isFinite(sec) || sec < 0) return text + const formatted = formatDurationMs(sec * 1000, { brightDate: opts?.brightDate }) + return text.replace(PROGRESS_ELAPSED_SUFFIX_RE, ` (${formatted})`) +} + +export type ActivityBrand = 'AGENT' | 'VISION' + +export interface ActivityPresentation { + brand: ActivityBrand + /** Primary activity bar label (uppercased in CSS). */ + headline: string + /** Optional secondary line under the headline. */ + detail?: string +} + +function stripElapsedSuffix(message: string): string { + return message.replace(PROGRESS_ELAPSED_SUFFIX_RE, '').trim() +} + +function inferFromProgressMessage(message: string, isAgent: boolean): ActivityPresentation | null { + const base = stripElapsedSuffix(message) + const lower = base.toLowerCase() + if (/running slash commands/.test(lower)) { + return { + brand: isAgent ? 'AGENT' : 'VISION', + headline: isAgent ? 'Running agent commands' : 'Running slash commands', + detail: base, + } + } + if (/preparing workspace/.test(lower)) { + return { + brand: isAgent ? 'AGENT' : 'VISION', + headline: 'Preparing workspace', + detail: base, + } + } + if (/waiting for/.test(lower)) { + return { + brand: isAgent ? 'AGENT' : 'VISION', + headline: 'Waiting for model', + detail: base, + } + } + return null +} + +function inferFromToolOutput(text: string): ActivityPresentation | null { + const t = text.trim() + const lower = t.toLowerCase() + if (/^tool call:/i.test(t)) { + const cmd = t.replace(/^tool call:\s*/i, '').trim() + return { + brand: 'AGENT', + headline: cmd ? `Tool: ${cmd.slice(0, 80)}` : 'Running tool', + detail: cmd.length > 80 ? cmd : undefined, + } + } + if (/executing shell command/i.test(lower)) { + return { brand: 'AGENT', headline: 'Executing shell command', detail: t.slice(0, 120) } + } + if (/^command:/i.test(t)) { + return { brand: 'AGENT', headline: 'Running shell command', detail: t.slice(0, 120) } + } + if (/^listed \d+ file/i.test(lower) || /^tool:\s*output/i.test(lower)) { + return { brand: 'AGENT', headline: 'Examining command response', detail: t.slice(0, 120) } + } + if (/^scanning repo|^updating repo map/i.test(lower)) { + return { brand: 'AGENT', headline: 'Scanning repository', detail: t.slice(0, 120) } + } + if (/exploring|let me start/i.test(lower) && t.length < 200) { + return { brand: 'AGENT', headline: 'Exploring project', detail: t.slice(0, 120) } + } + return null +} + +/** Map core progress / recent tool text to AGENT vs VISION activity bar copy. */ +export function buildActivityPresentation(input: { + processLabel: string + processDetail?: string + isAgentTurn: boolean + lastToolSnippet?: string + brightDate?: boolean +}): ActivityPresentation { + const detailRaw = input.processDetail?.trim() ?? '' + const detailFmt = detailRaw + ? formatProgressElapsedSuffix(detailRaw, { brightDate: input.brightDate }) + : undefined + + const fromTool = + input.isAgentTurn && input.lastToolSnippet + ? inferFromToolOutput(input.lastToolSnippet) + : null + if (fromTool && input.processLabel.toLowerCase() !== 'answering') { + return { + ...fromTool, + detail: detailFmt && detailFmt !== fromTool.headline ? detailFmt : fromTool.detail, + } + } + + if (detailFmt) { + const fromProgress = inferFromProgressMessage(detailFmt, input.isAgentTurn) + if (fromProgress) { + return { + brand: fromProgress.brand, + headline: fromProgress.headline, + detail: + fromProgress.detail && fromProgress.detail !== fromProgress.headline + ? fromProgress.detail + : undefined, + } + } + } + + const label = input.processLabel.trim() || 'Working' + return { + brand: input.isAgentTurn ? 'AGENT' : 'VISION', + headline: label, + detail: detailFmt && detailFmt !== label ? detailFmt : undefined, + } +} diff --git a/src/utils/sessionStall.test.ts b/src/utils/sessionStall.test.ts index 8842b15..3a64bd1 100644 --- a/src/utils/sessionStall.test.ts +++ b/src/utils/sessionStall.test.ts @@ -65,4 +65,18 @@ describe('sessionStall', () => { expect(isLikelyStalled(stalled)).toBe(true) expect(turnActivityHint(stalled, 9)).toContain('stuck') }) + + it('does not treat post-answer wait as stalled when tool output is recent', () => { + const now = 100_000 + const a = buildTurnActivity( + true, + now - 10 * 60_000, + now - 12 * 60_000, + 'Waiting for ollama', + now, + now - 30_000 + ) + expect(a.kind).toBe('tool') + expect(isLikelyStalled(a)).toBe(false) + }) }) diff --git a/src/utils/sessionStall.ts b/src/utils/sessionStall.ts index 7b0a246..51c5c80 100644 --- a/src/utils/sessionStall.ts +++ b/src/utils/sessionStall.ts @@ -1,6 +1,7 @@ /** Heuristics for “thinking” vs likely stall during an in-flight turn. */ import { isOllamaVisionModel } from '../ipc/localLlm' +import { formatDurationMs } from './thinkingTiming' export type TurnActivityKind = | 'idle' @@ -15,14 +16,25 @@ export interface TurnActivitySnapshot { kind: TurnActivityKind /** Ms since last SSE event of any type. */ sinceLastEventMs: number + /** Ms since last tool_output / tool_error (UI), or null if none this turn. */ + sinceLastToolMs: number | null + /** Ms since last meaningful activity (event, tool, or token). */ + sinceLastActivityMs: number /** Ms since last `token` event, or null if none this turn. */ sinceLastTokenMs: number | null lastProgressDetail: string } +export interface TurnActivityHintOptions { + isAgentTurn?: boolean + brightDate?: boolean +} + /** UI hint only — does not abort the turn (SSE idle timeout is separate). */ const STALL_WARN_MS = 300_000 const STREAMING_RECENT_MS = 8_000 +/** Recent tool output resets “stuck” heuristics (agent can be busy without tokens). */ +const TOOL_ACTIVITY_RECENT_MS = 90_000 /** No tokens / long Ollama wait — suggest Stop or Force FAST. */ const WAITING_STALL_MS = 8 * 60_000 const WAITING_WARN_MS = 3 * 60_000 @@ -41,12 +53,15 @@ export function buildTurnActivity( lastEventAt: number | null, lastTokenAt: number | null, lastProgressDetail: string, - now = Date.now() + now = Date.now(), + lastToolAt: number | null = null ): TurnActivitySnapshot { if (!isBusy || lastEventAt === null) { return { kind: 'idle', sinceLastEventMs: 0, + sinceLastToolMs: null, + sinceLastActivityMs: 0, sinceLastTokenMs: null, lastProgressDetail, } @@ -54,11 +69,19 @@ export function buildTurnActivity( const sinceLastEventMs = Math.max(0, now - lastEventAt) const sinceLastTokenMs = lastTokenAt !== null ? Math.max(0, now - lastTokenAt) : null + const sinceLastToolMs = + lastToolAt !== null ? Math.max(0, now - lastToolAt) : null + let lastActivityAt = lastEventAt + if (lastToolAt != null) lastActivityAt = Math.max(lastActivityAt, lastToolAt) + if (lastTokenAt != null) lastActivityAt = Math.max(lastActivityAt, lastTokenAt) + const sinceLastActivityMs = Math.max(0, now - lastActivityAt) const hay = lastProgressDetail.toLowerCase() let kind: TurnActivityKind = 'unknown' if (sinceLastTokenMs !== null && sinceLastTokenMs < STREAMING_RECENT_MS) { kind = 'streaming' + } else if (sinceLastToolMs !== null && sinceLastToolMs < TOOL_ACTIVITY_RECENT_MS) { + kind = 'tool' } else if ( PROGRESS_WORKING_RE.test(hay) && sinceLastTokenMs !== null && @@ -71,46 +94,49 @@ export function buildTurnActivity( kind = /confirm/.test(hay) ? 'confirm' : 'tool' } - return { sinceLastEventMs, sinceLastTokenMs, lastProgressDetail, kind } + return { + sinceLastEventMs, + sinceLastToolMs, + sinceLastActivityMs, + sinceLastTokenMs, + lastProgressDetail, + kind, + } } export function isLikelyStalled(activity: TurnActivitySnapshot): boolean { if (activity.kind === 'idle') return false if (activity.kind === 'streaming') return false + if (activity.kind === 'tool' || activity.kind === 'confirm') return false if (activity.kind === 'waiting_model' || activity.kind === 'post_answer_wait') { - if (activity.sinceLastEventMs >= WAITING_STALL_MS) return true - if ( - activity.kind === 'post_answer_wait' && - activity.sinceLastTokenMs !== null && - activity.sinceLastTokenMs >= WAITING_STALL_MS - ) { - return true - } + if (activity.sinceLastActivityMs >= WAITING_STALL_MS) return true return false } - if (activity.kind === 'tool' || activity.kind === 'confirm') { - return false - } - return activity.sinceLastEventMs >= STALL_WARN_MS + return activity.sinceLastActivityMs >= STALL_WARN_MS +} + +function formatStallDuration(ms: number, brightDate?: boolean): string { + return formatDurationMs(ms, { brightDate }) } function waitingModelHint( activity: TurnActivitySnapshot, queuedCount: number, - sessionModel: string | undefined + sessionModel: string | undefined, + brightDate?: boolean ): string { - const min = Math.round(activity.sinceLastEventMs / 60_000) + const idleLabel = formatStallDuration(activity.sinceLastActivityMs, brightDate) const local = isLocalLlmSession(sessionModel) let base = local ? 'Waiting for the local model (Ollama load can be idle on CPU — not the same as generating tokens).' : 'Waiting for the cloud LLM (API latency — not the same as streaming tokens yet).' - if (activity.sinceLastEventMs >= WAITING_STALL_MS) { + if (activity.sinceLastActivityMs >= WAITING_STALL_MS) { base = local - ? `Waiting for Ollama for ${min}+ min — likely stuck. Stop, Ping stack, check ollama ps, or Force FAST for UI-style tasks.` - : `Waiting for cloud LLM for ${min}+ min — likely stuck. Stop, verify OPENAI_API_KEY / OPENAI_API_BASE in the shell that launched the app, then retry.` - } else if (activity.sinceLastEventMs >= WAITING_WARN_MS) { + ? `Waiting for Ollama for ${idleLabel} — likely stuck. Stop, Ping stack, check ollama ps, or Force FAST for UI-style tasks.` + : `Waiting for cloud LLM for ${idleLabel} — likely stuck. Stop, verify OPENAI_API_KEY / OPENAI_API_BASE in the shell that launched the app, then retry.` + } else if (activity.sinceLastActivityMs >= WAITING_WARN_MS) { base += local ? ' Taking a long time — try Stop, Force FAST (chat bar), or Terminal → Local LLM → Start.' : ' Taking a long time — try Stop, confirm Settings model and cloud env, then retry.' @@ -124,9 +150,12 @@ function waitingModelHint( export function turnActivityHint( activity: TurnActivitySnapshot, queuedCount: number, - sessionModel?: string + sessionModel?: string, + opts?: TurnActivityHintOptions ): string { const local = isLocalLlmSession(sessionModel) + const agent = Boolean(opts?.isAgentTurn) + const brightDate = opts?.brightDate if (activity.kind === 'idle') { if (queuedCount > 0) { return `${queuedCount} message${queuedCount === 1 ? '' : 's'} queued — waiting for current turn to finish.` @@ -143,20 +172,37 @@ export function turnActivityHint( } if (activity.kind === 'waiting_model') { - return waitingModelHint(activity, queuedCount, sessionModel) + return waitingModelHint(activity, queuedCount, sessionModel, brightDate) + } + + if (activity.kind === 'tool') { + const base = agent + ? 'Agent is running tools — new chat tokens may pause while shell or repo work runs.' + : 'Tools are running — the model may not stream tokens during command output.' + if (queuedCount > 0) { + return `${base} ${queuedCount} message${queuedCount === 1 ? '' : 's'} queued until this turn completes.` + } + return base } if (activity.kind === 'post_answer_wait') { - const min = Math.round((activity.sinceLastTokenMs ?? activity.sinceLastEventMs) / 60_000) - let base = local - ? 'Answer is visible but the turn has not finished — core may be waiting on Ollama (check Settings → Ollama models /api/ps) or repo work. Queued /add messages will not run until the turn ends.' - : 'Answer is visible but the turn has not finished — core may be waiting on the cloud API or repo work. Queued /add messages will not run until the turn ends.' - if (activity.sinceLastEventMs >= WAITING_STALL_MS || (activity.sinceLastTokenMs ?? 0) >= WAITING_STALL_MS) { - base = local - ? `Answer visible but no progress for ${min}+ min — likely stuck on heavy Ollama or repo work. Stop, Force FAST, Ping stack, then retry.` - : `Answer visible but no progress for ${min}+ min — likely stuck on cloud LLM or repo work. Stop, verify cloud env, then retry.` - } else if (activity.sinceLastEventMs >= WAITING_WARN_MS) { - base += ' If this persists, Stop or Force FAST.' + const idleLabel = formatStallDuration( + activity.sinceLastTokenMs ?? activity.sinceLastActivityMs, + brightDate + ) + let base = agent + ? 'Answer is visible but the agent has not finished — it may still be waiting on the model, running tools, or repo work. Queued messages send when the agent completes this turn.' + : local + ? 'Answer is visible but the turn has not finished — core may be waiting on Ollama (check Settings → Ollama models /api/ps) or repo work. Queued /add messages will not run until the turn ends.' + : 'Answer is visible but the turn has not finished — core may be waiting on the cloud API or repo work. Queued /add messages will not run until the turn ends.' + if (activity.sinceLastActivityMs >= WAITING_STALL_MS) { + base = agent + ? `Answer visible but no progress for ${idleLabel} — agent may be stuck. Stop, Force FAST, or Ping stack, then retry.` + : local + ? `Answer visible but no progress for ${idleLabel} — likely stuck on heavy Ollama or repo work. Stop, Force FAST, Ping stack, then retry.` + : `Answer visible but no progress for ${idleLabel} — likely stuck on cloud LLM or repo work. Stop, verify cloud env, then retry.` + } else if (activity.sinceLastActivityMs >= WAITING_WARN_MS) { + base += agent ? ' If this persists, Stop the agent turn.' : ' If this persists, Stop or Force FAST.' } if (queuedCount > 0) { return `${base} Use Add all on suggested files while busy, Clear queue, or Stop — then Ping stack and retry.` @@ -165,11 +211,11 @@ export function turnActivityHint( } if (isLikelyStalled(activity)) { - const sec = Math.round(activity.sinceLastEventMs / 1000) + const sec = formatStallDuration(activity.sinceLastActivityMs, brightDate) const tail = local ? 'Try Stop, check Terminal / Ollama, then retry.' : 'Try Stop, check Terminal and cloud API env, then retry.' - return `No core activity for ${sec}s — likely stuck (not thinking). ${tail} Clear the queue if you queued many /add messages.` + return `No core activity for ${sec} — likely stuck (not thinking). ${tail} Clear the queue if you queued many /add messages.` } if (queuedCount > 0) { diff --git a/src/utils/suggestedFiles.test.ts b/src/utils/suggestedFiles.test.ts index 644943a..f7a2fca 100644 --- a/src/utils/suggestedFiles.test.ts +++ b/src/utils/suggestedFiles.test.ts @@ -4,6 +4,7 @@ import { buildSuggestedFileEntries, extractAnswerSection, extractSuggestedFilePaths, + filterDesignOutlinePaths, isAwaitingFilesCta, mergeSuggestedPaths, parseAddCommandPath, @@ -107,6 +108,22 @@ describe('suggestedFiles', () => { ).toEqual(['src/main.py', 'src/utils/foo.ts']) }) + it('excludes design-outline paths (planned modules, not on disk)', () => { + const design = `## bcurl Crate Architecture +- \`src/resolver.rs\`: BSLP resolver +- \`src/physics.rs\`: Light-floor physics +- \`src/engine.rs\`: Core async transfer engine +- \`src/progress.rs\`: Progress display +Use \`Cargo.toml\` for workspace deps.` + const paths = extractSuggestedFilePaths(design) + expect(paths).toContain('src/resolver.rs') + const filtered = filterDesignOutlinePaths(design, paths) + expect(filtered).not.toContain('src/resolver.rs') + expect(filtered).not.toContain('src/physics.rs') + expect(filtered).not.toContain('src/engine.rs') + expect(filtered).not.toContain('src/progress.rs') + }) + it('merges session tray without duplicates and respects files_in_chat', () => { const first = mergeSuggestedPaths([], KIRO_SPEC_ANSWER) expect(first.length).toBe(7) diff --git a/src/utils/suggestedFiles.ts b/src/utils/suggestedFiles.ts index 4e13ce5..4d7ce69 100644 --- a/src/utils/suggestedFiles.ts +++ b/src/utils/suggestedFiles.ts @@ -67,6 +67,31 @@ function collectBacktickPaths(block: string, found: Set) { } } +/** Design bullets like ``- `src/foo.rs`: module …`` — planned paths, not files to add. */ +const DESIGN_OUTLINE_LINE = new RegExp( + `^\\s*[-*]\\s+(?:\`([^\`]+)\`|(\\S+\\.(?:${FILE_EXT})))\\s*:`, + 'im' +) + +function pathMentionedAsDesignOutline(text: string, path: string): boolean { + const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const backtick = new RegExp(`\`${escaped}\`\\s*:`, 'm') + if (backtick.test(text)) return true + for (const line of text.split('\n')) { + const hit = line.match(DESIGN_OUTLINE_LINE) + if (!hit) continue + const raw = (hit[1] ?? hit[2] ?? '').trim() + const n = normalizeSuggestedPath(raw) + if (n === path) return true + } + return false +} + +/** Drop paths that look like planned modules in a design outline, not real files. */ +export function filterDesignOutlinePaths(text: string, paths: string[]): string[] { + return paths.filter((p) => !pathMentionedAsDesignOutline(text, p)) +} + /** Pull likely repo-relative paths from assistant Answer text or full message. */ export function extractSuggestedFilePaths(text: string): string[] { const found = new Set() @@ -154,14 +179,27 @@ export function buildSuggestedFileEntries( return paths.map((path) => ({ path, addCommand: `/add ${path}` })) } -/** Session tray: merge new paths from assistant text, dedupe, drop paths already in chat. */ -export function mergeSuggestedPaths( +/** Merge explicit path lists (e.g. after server-side existence filter). */ +export function mergeSuggestedPathLists( existing: string[], - assistantText: string, + incoming: string[], filesInChat: string[] = [] ): string[] { - const incoming = filterPathsNotInChat(extractSuggestedFilePaths(assistantText), filesInChat) const set = new Set(existing) for (const p of incoming) set.add(p) return filterPathsNotInChat([...set], filesInChat).sort() } + +/** Session tray: merge new paths from assistant text, dedupe, drop paths already in chat. */ +export function mergeSuggestedPaths( + existing: string[], + assistantText: string, + filesInChat: string[] = [] +): string[] { + const raw = filterDesignOutlinePaths( + assistantText, + extractSuggestedFilePaths(assistantText) + ) + const incoming = filterPathsNotInChat(raw, filesInChat) + return mergeSuggestedPathLists(existing, incoming, filesInChat) +} diff --git a/src/utils/thinkingStats.ts b/src/utils/thinkingStats.ts index daaaa1a..05ebee1 100644 --- a/src/utils/thinkingStats.ts +++ b/src/utils/thinkingStats.ts @@ -53,6 +53,13 @@ export interface TurnTimingRecord { avgGpuPct?: number | null /** Resource poll count for this turn (desktop). */ resourceSampleCount?: number + /** BrightDate bounds for the turn (core capture or computed client-side). */ + startBd?: number + endBd?: number + /** macOS memory pressure peak 0–2 when bgpucap/heartbeat logged it. */ + memPressurePeak?: number + /** `bgpucap` | `btime_only` | `heartbeat` from core turn_metrics. */ + captureMode?: string } export interface ThinkingStatsStore { @@ -213,6 +220,10 @@ export function recordTurnTiming( resourceSampleCount?: number tokensSent?: number tokensReceived?: number + startBd?: number + endBd?: number + memPressurePeak?: number + captureMode?: string } ): ThinkingStatsStore { if (sample.responseMs <= 0 && sample.thinkMs <= 0) return store @@ -244,6 +255,12 @@ export function recordTurnTiming( resourceSampleCount: sample.resourceSampleCount ?? 0, } : {}), + ...(sample.startBd != null ? { startBd: sample.startBd } : {}), + ...(sample.endBd != null ? { endBd: sample.endBd } : {}), + ...(sample.memPressurePeak != null + ? { memPressurePeak: sample.memPressurePeak } + : {}), + ...(sample.captureMode ? { captureMode: sample.captureMode } : {}), } return { version: 2, diff --git a/src/utils/thinkingTiming.ts b/src/utils/thinkingTiming.ts index 78a728f..3ce0dc4 100644 --- a/src/utils/thinkingTiming.ts +++ b/src/utils/thinkingTiming.ts @@ -1,3 +1,4 @@ +import { formatDurationMsBrightDate } from '@brightvision/vision-client' import type { AssistantSection, AssistantSectionKind } from './chatStream' import { getActiveAssistantSection } from './chatStream' @@ -35,8 +36,12 @@ export interface TurnThinkingTiming { thoughtMs: number } -export function formatDurationMs(ms: number): string { +export function formatDurationMs( + ms: number, + opts?: { brightDate?: boolean } +): string { if (!Number.isFinite(ms) || ms < 0) return '—' + if (opts?.brightDate) return formatDurationMsBrightDate(ms) if (ms < 1000) return `${Math.round(ms)}ms` const s = ms / 1000 if (s < 60) return `${s.toFixed(1)}s` diff --git a/src/utils/turnCapture.ts b/src/utils/turnCapture.ts new file mode 100644 index 0000000..4a2eba4 --- /dev/null +++ b/src/utils/turnCapture.ts @@ -0,0 +1,68 @@ +import type { CoreTurnCapture } from '@brightvision/vision-client' +import type { TurnResourceStats } from '../ipc/resourceSnapshot' + +/** Prefer core bgpucap/heartbeat peaks when higher than Tauri poll peaks. */ +export function mergeTurnCaptureWithResourceStats( + polled: TurnResourceStats | undefined, + capture: CoreTurnCapture | undefined +): TurnResourceStats | undefined { + if (!capture) return polled + const n = Math.max(capture.sampleCount ?? 0, polled?.sampleCount ?? 0) || 1 + const pickPeak = (a: number | null | undefined, b: number | null | undefined) => { + if (a == null || !Number.isFinite(a)) return b ?? undefined + if (b == null || !Number.isFinite(b)) return a + return Math.max(a, b) + } + const pickAvg = (a: number | undefined, b: number | undefined) => a ?? b + + if (!polled) { + if ( + capture.cpuPeak == null && + capture.memPeak == null && + capture.gpuPeak == null + ) { + return undefined + } + return { + peakCpuPct: capture.cpuPeak ?? 0, + peakMemPct: capture.memPeak ?? 0, + peakGpuPct: capture.gpuPeak ?? null, + avgCpuPct: capture.cpuAvg ?? capture.cpuPeak ?? 0, + avgMemPct: capture.memAvg ?? capture.memPeak ?? 0, + avgGpuPct: capture.gpuAvg ?? capture.gpuPeak ?? null, + sampleCount: n, + } + } + + return { + peakCpuPct: pickPeak(polled.peakCpuPct, capture.cpuPeak) ?? polled.peakCpuPct, + peakMemPct: pickPeak(polled.peakMemPct, capture.memPeak) ?? polled.peakMemPct, + peakGpuPct: + pickPeak(polled.peakGpuPct, capture.gpuPeak ?? null) ?? polled.peakGpuPct, + avgCpuPct: pickAvg(capture.cpuAvg, polled.avgCpuPct) ?? polled.avgCpuPct, + avgMemPct: pickAvg(capture.memAvg, polled.avgMemPct) ?? polled.avgMemPct, + avgGpuPct: + pickAvg(capture.gpuAvg ?? undefined, polled.avgGpuPct ?? undefined) ?? + polled.avgGpuPct, + sampleCount: n, + } +} + +export function turnCaptureExtras( + capture: CoreTurnCapture | undefined +): { + startBd?: number + endBd?: number + memPressurePeak?: number + captureMode?: string +} { + if (!capture) return {} + return { + ...(capture.startBd != null ? { startBd: capture.startBd } : {}), + ...(capture.endBd != null ? { endBd: capture.endBd } : {}), + ...(capture.memPressurePeak != null + ? { memPressurePeak: capture.memPressurePeak } + : {}), + ...(capture.captureMode ? { captureMode: capture.captureMode } : {}), + } +} diff --git a/src/utils/turnEtaEstimate.ts b/src/utils/turnEtaEstimate.ts index 1565a82..014223a 100644 --- a/src/utils/turnEtaEstimate.ts +++ b/src/utils/turnEtaEstimate.ts @@ -27,6 +27,8 @@ export interface TurnEtaInput { progressFraction?: number | null /** Live output TPS from current turn usage line when available. */ liveOutputTps?: number | null + /** Format durations/ETC as BrightDate (BD / md). */ + brightDate?: boolean } function clamp(n: number, min: number, max: number): number { @@ -43,6 +45,7 @@ function promptScale(promptChars: number, baselineChars: number): number { * optional progress fraction, and historical output TPS when logged. */ export function estimateTurnEta(input: TurnEtaInput): TurnEtaEstimate { + const fmt = (ms: number) => formatDurationMs(ms, { brightDate: input.brightDate }) const model = input.model.trim() || 'unknown' const view = buildTimingStatsView(input.statsStore, model) const n = view.response.count @@ -85,9 +88,9 @@ export function estimateTurnEta(input: TurnEtaInput): TurnEtaEstimate { const lines: string[] = [ `Model: ${model}`, - `History: ${n} turn${n === 1 ? '' : 's'} (median ${formatDurationMs(median)}, p90 ${formatDurationMs(p90)})`, + `History: ${n} turn${n === 1 ? '' : 's'} (median ${fmt(median)}, p90 ${fmt(p90)})`, `Prompt scale: ×${scale.toFixed(2)} (${input.promptChars.toLocaleString()} chars vs ~${Math.round(avgPrompt).toLocaleString()} avg)`, - `Estimated total: ~${formatDurationMs(Math.round(estimatedTotal))}`, + `Estimated total: ~${fmt(Math.round(estimatedTotal))}`, ] if (tps != null && tps > 0) { @@ -102,7 +105,7 @@ export function estimateTurnEta(input: TurnEtaInput): TurnEtaEstimate { lines.push(`Progress: ${Math.round(progress * 100)}% (blended into estimate)`) } - lines.push(`Typical range: ${formatDurationMs(remaining)} – ${formatDurationMs(p90Remaining)} remaining`) + lines.push(`Typical range: ${fmt(remaining)} – ${fmt(p90Remaining)} remaining`) lines.push( 'GPU % (when logged) is not used for this estimate — correlation with time-left needs dogfood data (#34).' ) @@ -117,8 +120,7 @@ export function estimateTurnEta(input: TurnEtaInput): TurnEtaEstimate { estimatedTotal = Math.max(estimatedTotal, input.elapsedMs + remaining) } - const shortLabel = - remaining > 0 ? `~${formatDurationMs(remaining)} left*` : null + const shortLabel = remaining > 0 ? `~${fmt(remaining)} left*` : null return { remainingMs: remaining > 0 ? remaining : null, diff --git a/src/utils/turnsTable.ts b/src/utils/turnsTable.ts new file mode 100644 index 0000000..9a1cd3e --- /dev/null +++ b/src/utils/turnsTable.ts @@ -0,0 +1,47 @@ +import type { ThinkingStatsStore } from './thinkingStats' +import { formatDurationMs } from './thinkingTiming' +import { formatOutputTps, computeOutputTps, thinkShare } from './thinkingStats' + +function formatMemPressure(peak?: number): string { + if (peak == null || !Number.isFinite(peak)) return '—' + const labels = ['normal', 'warn', 'critical'] as const + return labels[peak] ?? String(peak) +} + +/** Markdown table of recent turn timings for in-chat `/turns`. */ +export function formatTurnsTableMarkdown( + store: ThinkingStatsStore, + opts?: { brightDate?: boolean; maxRows?: number } +): string { + const rows = store.history.slice(0, opts?.maxRows ?? 12) + if (!rows.length) { + return 'No completed turns recorded yet. Finish a chat turn with **Thinking timers** enabled, then run `/turns` again.' + } + const fmt = { brightDate: opts?.brightDate } + const lines = [ + '| When | Model | Response | Think | TPS | Mem pressure |', + '| --- | --- | --- | --- | --- | --- |', + ] + for (const row of rows) { + const when = new Date(row.at).toLocaleString() + const model = row.model.length > 28 ? `${row.model.slice(0, 25)}…` : row.model + const tps = formatOutputTps(computeOutputTps(row.tokensReceived, row.responseMs)) + const thinkPct = + thinkShare(row) != null ? `${Math.round((thinkShare(row) ?? 0) * 100)}%` : '—' + lines.push( + `| ${when} | ${model} | ${formatDurationMs(row.responseMs, fmt)} | ${formatDurationMs(row.thinkMs, fmt)} (${thinkPct}) | ${tps} | ${formatMemPressure(row.memPressurePeak)} |` + ) + } + return lines.join('\n') +} + +export function appendTurnsTableToChat( + store: ThinkingStatsStore, + appendSystemMessage: (content: string) => void, + opts?: { brightDate?: boolean } +): void { + const table = formatTurnsTableMarkdown(store, opts) + appendSystemMessage( + `**Turn history** (local stats; Settings → Session history for full export)\n\n${table}` + ) +} diff --git a/src/utils/workspacePath.test.ts b/src/utils/workspacePath.test.ts new file mode 100644 index 0000000..b9e3a0c --- /dev/null +++ b/src/utils/workspacePath.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeWorkspacePath, workspacePathsEqual } from './workspacePath' + +describe('workspacePath', () => { + it('normalizes slashes and trailing slashes', () => { + expect(normalizeWorkspacePath('/Volumes/Code/foo/')).toBe('/Volumes/Code/foo') + expect(normalizeWorkspacePath('C:\\repo\\')).toBe('C:/repo') + }) + + it('compares paths after normalization', () => { + expect(workspacePathsEqual('/a/b/', '/a/b')).toBe(true) + expect(workspacePathsEqual('/a/b', '/a/c')).toBe(false) + }) +}) diff --git a/src/utils/workspacePath.ts b/src/utils/workspacePath.ts new file mode 100644 index 0000000..133b815 --- /dev/null +++ b/src/utils/workspacePath.ts @@ -0,0 +1,11 @@ +/** Normalize workspace paths for stable UI/API comparison (no filesystem resolve). */ +export function normalizeWorkspacePath(path: string): string { + const trimmed = path.trim().replace(/\\/g, '/') + if (!trimmed) return '.' + const stripped = trimmed.replace(/\/+$/, '') + return stripped || '/' +} + +export function workspacePathsEqual(a: string, b: string): boolean { + return normalizeWorkspacePath(a) === normalizeWorkspacePath(b) +} diff --git a/tests/core/test_agent_turn.py b/tests/core/test_agent_turn.py new file mode 100644 index 0000000..89f619e --- /dev/null +++ b/tests/core/test_agent_turn.py @@ -0,0 +1,103 @@ +"""Tests for incomplete /agent turn detection.""" + +from pathlib import Path + +from bright_vision_core.agent_turn import ( + extract_prose_shell_commands, + incomplete_agent_warning, + is_safe_readonly_shell, + is_tool_activity_event, + run_prose_shell_recovery, +) + + +def test_is_tool_activity_event_tool_call(): + assert is_tool_activity_event({"type": "tool_call", "text": "shell find ."}) + + +def test_is_tool_activity_event_tool_output(): + assert is_tool_activity_event({"type": "tool_output", "text": "Cargo.toml\n"}) + + +def test_is_tool_activity_event_ignores_empty_and_token_stats(): + assert not is_tool_activity_event({"type": "tool_output", "text": ""}) + assert not is_tool_activity_event({"type": "tool_output", "text": "41k ↑ 181 ↓"}) + assert not is_tool_activity_event({"type": "token", "text": "hello"}) + + +def test_extract_prose_shell_commands(): + text = ( + "Explore.\n\n" + "```bash\nfind . -name \"Cargo.toml\" | head -30\n```" + ) + assert extract_prose_shell_commands(text) == [ + 'find . -name "Cargo.toml" | head -30', + ] + + +def test_is_safe_readonly_shell_blocks_destructive(): + assert is_safe_readonly_shell("find . -name Cargo.toml") + assert not is_safe_readonly_shell("rm -rf .") + assert not is_safe_readonly_shell("curl http://evil") + + +def test_is_safe_readonly_shell_allows_find_pipe_head_and_curl_reference_path(): + cmd = ( + 'find . -name "Cargo.toml" -not -path "./vendor/*" ' + '-not -path "./curl-reference-code/*" | head -30' + ) + assert is_safe_readonly_shell(cmd) + + +def test_run_prose_shell_recovery_find(tmp_path: Path): + (tmp_path / "Cargo.toml").write_text("[package]\nname = \"x\"\n", encoding="utf-8") + out = run_prose_shell_recovery(tmp_path, 'find . -name "Cargo.toml"') + assert out is not None + assert "Cargo.toml" in out + + +def test_incomplete_agent_warning_prose_shell_without_tools(): + text = ( + "I'll explore the repo.\n\n" + "```bash\nfind . -maxdepth 2 -name \"Cargo.toml\" | head -30\n```\n" + ) + msg = incomplete_agent_warning(text, had_tool_activity=False) + assert msg is not None + assert "without running tools" in msg + + +def test_incomplete_agent_warning_suppressed_when_tools_ran(): + text = "```bash\nls\n```" + assert incomplete_agent_warning(text, had_tool_activity=True) is None + + +def test_incomplete_agent_warning_suppressed_without_shell_fence(): + assert incomplete_agent_warning("I'll list files next.", had_tool_activity=False) is None + + +def test_is_agent_shell_only_stop(): + from bright_vision_core.agent_turn import is_agent_shell_only_stop + + assert is_agent_shell_only_stop(had_tool_activity=True, had_tool_call=False) + assert not is_agent_shell_only_stop(had_tool_activity=True, had_tool_call=True) + assert not is_agent_shell_only_stop(had_tool_activity=False, had_tool_call=False) + + +def test_empty_agent_turn_warning(): + from bright_vision_core.agent_turn import empty_agent_turn_warning + + msg = empty_agent_turn_warning(had_tool_activity=False, assistant_text="") + assert msg is not None + assert "/agent finished immediately" in msg + assert empty_agent_turn_warning(had_tool_activity=True, assistant_text="") is None + assert empty_agent_turn_warning(had_tool_activity=False, assistant_text="hi") is None + + +def test_shell_output_in_events(): + from bright_vision_core.agent_turn import shell_output_in_events + + assert shell_output_in_events([{"type": "tool_output", "text": "Running find ."}]) + assert shell_output_in_events( + [{"type": "tool_output", "text": "Recovered prose shell (read-only):\n$ find\n."}] + ) + assert not shell_output_in_events([{"type": "tool_output", "text": "41k ↑ 256 ↓"}]) diff --git a/tests/core/test_brightdate_timing.py b/tests/core/test_brightdate_timing.py index 40dd3c3..bad8e17 100644 --- a/tests/core/test_brightdate_timing.py +++ b/tests/core/test_brightdate_timing.py @@ -5,7 +5,10 @@ import os import unittest -from bright_vision_core.test_suite.brightdate_timing import ( +from bright_vision_core.brightdate import ( + J2000_UNIX_MS, + bd_add_seconds, + bd_from_unix_ms, format_bd_scalar, format_elapsed_brightdate, format_etc_brightdate, @@ -27,6 +30,20 @@ class TestBrightdateTiming(unittest.TestCase): + def test_j2000_epoch_matches_btime_and_spec(self): + """BD 0 at spec UTC label; aligns with btime start/end lines (not ~0.5 BD high).""" + self.assertEqual(J2000_UNIX_MS, 946_727_935_816) + self.assertAlmostEqual(bd_from_unix_ms(J2000_UNIX_MS), 0.0, places=9) + start, end = parse_btime_bd_bounds(BTIME_SAMPLE) + assert start is not None and end is not None + self.assertGreater(end, start) + self.assertLess(end - start, 0.001) + + def test_etc_additive_matches_wall_clock(self): + base = 9648.25 + etc_bd = bd_add_seconds(base, 86.4) # 1 md + self.assertAlmostEqual(etc_bd, base + 0.001, places=6) + def test_parse_btime_bd_bounds(self): start, end = parse_btime_bd_bounds(BTIME_SAMPLE) self.assertAlmostEqual(start or 0, 9648.252074201, places=6) diff --git a/tests/core/test_cecli_tool_json.py b/tests/core/test_cecli_tool_json.py index 8e2b98c..d548825 100644 --- a/tests/core/test_cecli_tool_json.py +++ b/tests/core/test_cecli_tool_json.py @@ -18,13 +18,13 @@ def _have_cecli_helpers() -> bool: @unittest.skipUnless(_have_cecli_helpers(), "requires cecli submodule") class TestCecliToolJsonSubmodule(unittest.TestCase): def test_parse_tool_arguments_merges_glued_empty_object_fragments(self): - from cecli.tools.utils.helpers import parse_tool_arguments + from cecli.helpers.responses import parse_tool_arguments raw = '{"limit": 15}{}{"path": "."}' self.assertEqual(parse_tool_arguments(raw), {"limit": 15, "path": "."}) def test_merge_glued_json_objects_rejects_array_chunks(self): - from cecli.tools.utils.helpers import merge_glued_json_objects + from cecli.helpers.responses import merge_glued_json_objects self.assertIsNone(merge_glued_json_objects(['["a"]', '{"b": 1}'])) diff --git a/tests/core/test_event_io_agent_confirm.py b/tests/core/test_event_io_agent_confirm.py new file mode 100644 index 0000000..f3dd3ac --- /dev/null +++ b/tests/core/test_event_io_agent_confirm.py @@ -0,0 +1,57 @@ +"""EventIO agent auto-confirm during /agent preproc.""" + +from __future__ import annotations + +import asyncio + +from bright_vision_core.event_io import EventIO + + +def test_agent_auto_confirm_bypasses_explicit_yes_required() -> None: + io = EventIO(yes=False) + with io.agent_auto_confirm(): + assert ( + asyncio.run( + io.confirm_ask("Add file to the chat?", explicit_yes_required=True, subject="Cargo.toml") + ) + is True + ) + assert io.events[-1]["type"] == "confirm" + assert io.events[-1]["auto_answered"] is True + + +def test_agent_auto_confirm_depth_resets() -> None: + io = EventIO(yes=False) + with io.agent_auto_confirm(): + assert io._agent_auto_confirm_depth == 1 + assert io._agent_auto_confirm_depth == 0 + + +def test_confirm_skips_add_file_when_already_in_chat() -> None: + io = EventIO(yes=False) + io.set_chat_rel_files(["Cargo.toml"]) + assert asyncio.run(io.confirm_ask("Add file to the chat?", subject="Cargo.toml")) is True + assert io.events[-1]["auto_answered"] is True + + +def test_confirm_skips_add_file_by_basename() -> None: + io = EventIO(yes=False) + io.set_chat_rel_files(["crates/foo/Cargo.toml"]) + assert asyncio.run(io.confirm_ask("Add file to the chat?", subject="Cargo.toml")) is True + assert io.events[-1]["auto_answered"] is True + + +def test_agent_mode_active_auto_confirms() -> None: + io = EventIO(yes=False) + io._agent_mode_active = True + assert ( + asyncio.run( + io.confirm_ask( + "Run shell command?", + subject="find .", + explicit_yes_required=True, + ) + ) + is True + ) + assert io.events[-1]["auto_answered"] is True diff --git a/tests/core/test_filter_workspace_paths_api.py b/tests/core/test_filter_workspace_paths_api.py new file mode 100644 index 0000000..40bf651 --- /dev/null +++ b/tests/core/test_filter_workspace_paths_api.py @@ -0,0 +1,28 @@ +"""POST /workspaces/filter-paths.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from bright_vision_core.http_api import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +def test_filter_workspace_paths(client: TestClient, tmp_path: Path): + f = tmp_path / "README.md" + f.write_text("hi\n", encoding="utf-8") + res = client.post( + f"/workspaces/filter-paths?workspace={tmp_path}", + json={"paths": ["README.md", "nope.txt"]}, + ) + assert res.status_code == 200 + data = res.json() + assert data["existing"] == ["README.md"] + assert data["missing"] == ["nope.txt"] diff --git a/tests/core/test_headless_args.py b/tests/core/test_headless_args.py index 20dec3e..70d0c7d 100644 --- a/tests/core/test_headless_args.py +++ b/tests/core/test_headless_args.py @@ -29,6 +29,11 @@ "auto_save_session_name", "session_encrypt", "session_key_file", + "attribute_author", + "attribute_committer", + "attribute_co_authored_by", + "attribute_commit_message_author", + "attribute_commit_message_committer", ) diff --git a/tests/core/test_http_api.py b/tests/core/test_http_api.py index 09608df..68f5b38 100644 --- a/tests/core/test_http_api.py +++ b/tests/core/test_http_api.py @@ -30,7 +30,9 @@ def test_health(self): client = TestClient(app) res = client.get("/health") self.assertEqual(res.status_code, 200) - self.assertEqual(res.json()["status"], "ok") + body = res.json() + self.assertEqual(body["status"], "ok") + self.assertTrue(body.get("agent_turn_features", {}).get("prose_shell_recovery")) def test_create_session_missing_workspace(self): client = TestClient(app) diff --git a/tests/core/test_local_workspace.py b/tests/core/test_local_workspace.py new file mode 100644 index 0000000..6e27e2c --- /dev/null +++ b/tests/core/test_local_workspace.py @@ -0,0 +1,173 @@ +"""Multi-repo workspace (.cecli.workspaces.yml with path: projects).""" + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + + +class TestLocalWorkspaceConfig(unittest.TestCase): + def test_validate_path_or_repo_exclusive(self): + from cecli.helpers.monorepo.config import validate_config + + validate_config( + { + "name": "ws", + "projects": [{"name": "app", "path": "/tmp/app", "primary": True}], + } + ) + with self.assertRaises(ValueError): + validate_config( + { + "name": "ws", + "projects": [{"name": "bad", "path": "/a", "repo": "https://example.com/x.git"}], + } + ) + + def test_union_tracked_files_two_repos(self): + from cecli.helpers.monorepo.config import load_workspace_config_file + from cecli.helpers.monorepo.local_workspace import union_tracked_files + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + a = root / "a" + b = root / "b" + for repo in (a, b): + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "README.md").write_text(f"# {repo.name}\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "init", "--author", "t ", "--no-gpg-sign"], + cwd=repo, + check=True, + capture_output=True, + ) + ws_path = root / ".cecli.workspaces.yml" + ws_path.write_text( + yaml.dump( + { + "name": "pair", + "projects": [ + {"name": "a", "path": str(a), "primary": True}, + {"name": "b", "path": str(b)}, + ], + } + ), + encoding="utf-8", + ) + cfg = load_workspace_config_file(ws_path) + files = union_tracked_files(root, cfg, layout="local") + self.assertIn("a/README.md", files) + self.assertIn("b/README.md", files) + + def test_create_git_workspace_prefers_yaml_over_submodules(self): + from cecli.helpers.monorepo.local_workspace import find_workspace_config_file + from bright_vision_core.git_workspace import create_git_workspace + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True) + (root / ".cecli.workspaces.yml").write_text( + yaml.dump( + { + "name": "solo", + "projects": [{"name": "solo", "path": str(root), "primary": True}], + } + ), + encoding="utf-8", + ) + self.assertIsNotNone(find_workspace_config_file(root)) + + class _Io: + def tool_error(self, *a, **k): + pass + + repo = create_git_workspace(_Io(), [str(root)], str(root)) + from cecli.repo import GitRepo + + self.assertIsInstance(repo, GitRepo) + self.assertTrue(getattr(repo, "is_workspace", False)) + self.assertEqual(getattr(repo, "workspace_layout", None), "local") + + def test_describe_cecli_workspace_absent(self): + from bright_vision_core.workspace_config import describe_cecli_workspace + + with tempfile.TemporaryDirectory() as tmp: + info = describe_cecli_workspace(Path(tmp)) + self.assertFalse(info["present"]) + self.assertEqual(info["project_count"], 0) + + def test_find_workspace_config_file_walks_up(self): + from cecli.helpers.monorepo.local_workspace import find_workspace_config_file + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sub = root / "nested" / "repo" + sub.mkdir(parents=True) + (root / ".cecli.workspaces.yml").write_text( + "name: ws\nprojects: []\n", + encoding="utf-8", + ) + found = find_workspace_config_file(sub) + self.assertIsNotNone(found) + self.assertEqual(found.resolve(), (root / ".cecli.workspaces.yml").resolve()) + + def test_describe_cecli_workspace_with_projects(self): + from bright_vision_core.workspace_config import describe_cecli_workspace + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cecli.workspaces.yml").write_text( + yaml.dump( + { + "name": "pair", + "projects": [ + {"name": "a", "path": "/tmp/a", "primary": True}, + {"name": "b", "path": "/tmp/b"}, + ], + } + ), + encoding="utf-8", + ) + info = describe_cecli_workspace(root) + self.assertTrue(info["present"]) + self.assertEqual(info["project_count"], 2) + self.assertEqual(info["name"], "pair") + + def test_http_cecli_workspace_endpoint(self): + from fastapi.testclient import TestClient + + from bright_vision_core.http_api import app + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".cecli.workspaces.yml").write_text( + "name: solo\nprojects:\n - name: solo\n path: /x\n primary: true\n", + encoding="utf-8", + ) + client = TestClient(app) + res = client.get(f"/workspaces/cecli-workspace?workspace={root}") + self.assertEqual(res.status_code, 200) + body = res.json() + self.assertTrue(body["present"]) + self.assertEqual(body["project_count"], 1) + + def test_ensure_workspaces_file_no_overwrite(self): + from bright_vision_core.workspace_config import ensure_workspaces_file, workspaces_file_in_project + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + existing = root / ".cecli.workspaces.yml" + existing.write_text("name: kept\nprojects: []\n", encoding="utf-8") + ensure_workspaces_file(root, {"name": "new", "projects": []}) + self.assertIn("kept", existing.read_text(encoding="utf-8")) + self.assertIsNotNone(workspaces_file_in_project(root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_model_router.py b/tests/core/test_model_router.py index 827d8cb..8a113fb 100644 --- a/tests/core/test_model_router.py +++ b/tests/core/test_model_router.py @@ -39,6 +39,22 @@ def test_classify_architect_heavy(): assert d.tier == "heavy" +def test_classify_agent_command_heavy(): + router = ModelRouterConfig( + enabled=True, + fast_model="ollama_chat/small", + heavy_model="ollama_chat/big", + ) + d = classify_prompt( + "/agent explore the repo and update the checklist", + message_tokens=400, + router=router, + heavy_model_name="ollama_chat/big", + ) + assert d.tier == "heavy" + assert "slash:/agent" in d.reasons + + def test_classify_high_message_tokens_heavy(): router = ModelRouterConfig( enabled=True, diff --git a/tests/core/test_session_debug.py b/tests/core/test_session_debug.py index 3f20036..d312c64 100644 --- a/tests/core/test_session_debug.py +++ b/tests/core/test_session_debug.py @@ -61,6 +61,7 @@ def test_build_includes_tool_calls_and_duplicate_hints(self): self.assertEqual(len(payload["tool_invocations"]), 3) self.assertGreaterEqual(len(payload["duplicate_tool_call_hints"]), 1) self.assertGreaterEqual(len(payload["recent_io_events"]), 1) + self.assertIn("prose_shell_recovery", payload.get("agent_turn_features", {})) text = json.dumps(payload) self.assertIn("GitLog", text) diff --git a/tests/core/test_slash_helpers.py b/tests/core/test_slash_helpers.py index b531d77..7557988 100644 --- a/tests/core/test_slash_helpers.py +++ b/tests/core/test_slash_helpers.py @@ -4,7 +4,43 @@ from cecli.commands import SwitchCoderSignal -from bright_vision_core.slash_helpers import is_switch_coder_signal +from bright_vision_core.slash_helpers import ( + is_switch_coder_signal, + resolve_slash_command_name, + synthetic_slash_preproc_input, +) + + +class _MockCommands: + def is_command(self, inp: str) -> bool: + return inp.strip().startswith("/") + + def matching_commands(self, inp: str): + words = inp.strip().split(maxsplit=1) + if not words: + return None + first = words[0] + rest = inp.strip()[len(first) :].strip() + return [first], first, rest + + +def test_synthetic_slash_preproc_input_with_task_inject() -> None: + commands = _MockCommands() + message = "/agent Implement the active task" + user_text = "[Active task: Explore · id abc]\n\n---\n" + message + got = synthetic_slash_preproc_input(message, user_text, commands) + assert got == "/agent [Active task: Explore · id abc]\n\n---\nImplement the active task" + + +def test_synthetic_slash_preproc_input_passthrough_when_already_command() -> None: + commands = _MockCommands() + message = "/agent go" + assert synthetic_slash_preproc_input(message, message, commands) is None + + +def test_resolve_slash_command_name_on_raw_message() -> None: + commands = _MockCommands() + assert resolve_slash_command_name("/agent explore", commands) == "agent" def test_is_switch_coder_signal_direct() -> None: diff --git a/tests/core/test_slash_preproc_timeout.py b/tests/core/test_slash_preproc_timeout.py index a7d7e6f..412acfe 100644 --- a/tests/core/test_slash_preproc_timeout.py +++ b/tests/core/test_slash_preproc_timeout.py @@ -47,6 +47,22 @@ def test_agent_optional_cap(commands, monkeypatch) -> None: assert slash_preproc_timeout_s("/agent explore the repo", commands) == 600.0 +def test_agent_no_cap_when_task_injected(commands, monkeypatch) -> None: + monkeypatch.delenv("VISION_AGENT_PREPROC_TIMEOUT_S", raising=False) + message = "/agent Implement the active task" + user_text = "[Active task: Explore · id abc]\n\n---\n" + message + assert slash_preproc_timeout_s(user_text, commands) == 300.0 + assert ( + slash_preproc_timeout_s( + user_text, + commands, + message=message, + agent_cmd=True, + ) + is None + ) + + def test_fast_slash_keeps_cap(commands, monkeypatch) -> None: monkeypatch.setenv("VISION_SLASH_PREPROC_TIMEOUT_S", "120") assert slash_preproc_timeout_s("/add src/foo.ts", commands) == 120.0 diff --git a/tests/core/test_spec_focus.py b/tests/core/test_spec_focus.py index 3f6f92d..9a053a9 100644 --- a/tests/core/test_spec_focus.py +++ b/tests/core/test_spec_focus.py @@ -12,7 +12,7 @@ spec_focus_requested, todo_has_spec_content, ) -from bright_vision_core.workspace_todos import TodoItem, TodoStore, migrate_todo_layers +from bright_vision_core.workspace_todos import TodoItem, TodoStore, migrate_todo_layers, ChecklistItem def _item( @@ -66,6 +66,13 @@ def test_empty_layers_not_spec_content(self): spec_focus_preamble_applies(focus_requested=True, item=item) ) + def test_tasks_md_alone_not_spec_content(self): + item = _item(tasks_md="- [ ] Explore project structure\n- [ ] Ship feature") + self.assertFalse(todo_has_spec_content(item)) + self.assertFalse( + spec_focus_preamble_applies(focus_requested=True, item=item) + ) + def test_layers_with_requirements_is_spec_content(self): item = _item(requirements="### REQ-001\n**WHEN** x **THE** system **SHALL** y") self.assertTrue(todo_has_spec_content(item)) @@ -122,6 +129,44 @@ def test_inject_without_preamble_when_layers_empty(self): self.assertEqual(tid, item.id) self.assertIn("[Active task:", text) self.assertNotIn("Spec-focus mode", text) + self.assertNotIn("(No requirements yet.)", text) + + def test_light_inject_for_checklist_task(self): + with tempfile.TemporaryDirectory() as tmp: + now = "2026-01-01T00:00:00Z" + item = migrate_todo_layers( + TodoItem( + id="task-2", + title="Explore repo", + spec="", + requirements="", + design="", + tasks_md="", + depends_on=[], + branch="", + pr_url="", + status="open", + links=[], + checklist=[ + ChecklistItem(id="c1", text="List crates", done=False), + ], + created_at=now, + updated_at=now, + ) + ) + store = TodoStore(version=1, active_id=item.id, todos=[item]) + text, active, tid = build_user_message_with_spec_context( + tmp, + "/agent go", + item=item, + store=store, + focus_requested=False, + inject_todo_spec=True, + ) + self.assertEqual(tid, item.id) + self.assertIn("## Checklist", text) + self.assertIn("List crates", text) + self.assertNotIn("Requirements", text) if __name__ == "__main__": diff --git a/tests/core/test_turn_metrics.py b/tests/core/test_turn_metrics.py new file mode 100644 index 0000000..6200e98 --- /dev/null +++ b/tests/core/test_turn_metrics.py @@ -0,0 +1,75 @@ +"""Turn-level resource capture (bgpucap + heartbeat fallback).""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest.mock import MagicMock, patch + +from bright_vision_core.turn_metrics import TurnMetricsCollector + + +class TestTurnMetrics(unittest.TestCase): + def test_stop_returns_capture_with_bd_bounds(self): + collector = TurnMetricsCollector() + with patch( + "bright_vision_core.turn_metrics.resolve_capture_mode", + return_value="btime_only", + ): + with patch( + "bright_vision_core.turn_metrics.sample_utilization", + return_value=type( + "S", + (), + { + "cpu_pct": 10.0, + "gpu_pct": None, + "mem_pct": 50.0, + "mem_pressure": 1.0, + }, + )(), + ): + collector.start() + cap = collector.stop() + self.assertIsNotNone(cap) + assert cap is not None + self.assertGreater(cap.end_bd, cap.start_bd) + self.assertEqual(cap.capture_mode, "btime_only") + self.assertGreaterEqual(cap.sample_count, 1) + + def test_attach_turn_capture_idempotent(self): + collector = TurnMetricsCollector() + with patch( + "bright_vision_core.turn_metrics.resolve_capture_mode", + return_value="off", + ): + collector.start() + first = collector.stop() + second = collector.stop() + self.assertIsNotNone(first) + self.assertIsNone(second) + + def test_bgpucap_shutdown_timeout_does_not_raise(self): + collector = TurnMetricsCollector() + collector._active = True + collector._start_unix = 1.0 + proc = MagicMock() + proc.communicate.side_effect = subprocess.TimeoutExpired(cmd=["bgpucap"], timeout=5) + collector._bgpucap_proc = proc + collector._capture_mode = "bgpucap" + with patch( + "bright_vision_core.turn_metrics.sample_utilization", + return_value=type( + "S", + (), + {"cpu_pct": 1.0, "gpu_pct": None, "mem_pct": 1.0, "mem_pressure": None}, + )(), + ): + with patch("bright_vision_core.turn_metrics.time.time", return_value=2.0): + cap = collector.stop() + self.assertIsNotNone(cap) + proc.kill.assert_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_workspace_files.py b/tests/core/test_workspace_files.py new file mode 100644 index 0000000..3c52d67 --- /dev/null +++ b/tests/core/test_workspace_files.py @@ -0,0 +1,32 @@ +"""Workspace path existence and spec-layer edit detection.""" + +from __future__ import annotations + +from pathlib import Path + +from bright_vision_core.workspace_files import ( + edited_spec_layers_for_todo, + filter_existing_workspace_paths, +) + + +def test_filter_existing_workspace_paths(tmp_path: Path): + f = tmp_path / "src" / "real.rs" + f.parent.mkdir(parents=True) + f.write_text("fn main() {}\n", encoding="utf-8") + existing, missing = filter_existing_workspace_paths( + tmp_path, + ["src/real.rs", "src/ghost.rs", "../outside.rs"], + ) + assert existing == ["src/real.rs"] + assert missing == ["src/ghost.rs", "../outside.rs"] + + +def test_edited_spec_layers_for_todo(): + tid = "12926a1aadec47208e7f9a23d56bff7e" + edited = [ + ".cecli/specs/12926a1a/requirements.md", + "Cargo.toml", + ] + assert edited_spec_layers_for_todo(edited, tid) is True + assert edited_spec_layers_for_todo(["Cargo.toml"], tid) is False diff --git a/tests/core/test_workspace_todos.py b/tests/core/test_workspace_todos.py index c470a5a..2b1e3e1 100644 --- a/tests/core/test_workspace_todos.py +++ b/tests/core/test_workspace_todos.py @@ -54,6 +54,42 @@ def test_import_spec_files(self): loaded = api.import_spec_files(item.id) self.assertIn("Updated", loaded.requirements) + def test_import_spec_files_short_folder_id(self): + with GitTemporaryDirectory() as temp_dir: + root = Path(temp_dir) + make_repo(root) + api = WorkspaceTodos(root) + item = api.add("Spec task") + full_dir = api.specs_root / item.id + if full_dir.is_dir(): + import shutil + + shutil.rmtree(full_dir) + short = item.id[:8] + spec_dir = api.specs_root / short + spec_dir.mkdir(parents=True, exist_ok=True) + (spec_dir / "requirements.md").write_text("### REQ-1\nFrom disk", encoding="utf-8") + loaded = api.import_spec_files(item.id) + self.assertIn("From disk", loaded.requirements) + + def test_maybe_import_spec_from_disk_when_layers_empty(self): + with GitTemporaryDirectory() as temp_dir: + root = Path(temp_dir) + make_repo(root) + api = WorkspaceTodos(root) + item = api.add("Spec task") + full_dir = api.specs_root / item.id + if full_dir.is_dir(): + import shutil + + shutil.rmtree(full_dir) + short = item.id[:8] + spec_dir = api.specs_root / short + spec_dir.mkdir(parents=True, exist_ok=True) + (spec_dir / "requirements.md").write_text("### REQ-1\nAuto", encoding="utf-8") + loaded = api.maybe_import_spec_from_disk(item) + self.assertIn("Auto", loaded.requirements) + def test_delete_removes_spec_folder(self): with GitTemporaryDirectory() as temp_dir: root = Path(temp_dir) diff --git a/yarn.lock b/yarn.lock index 2a81fa0..d53d67f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1375,6 +1375,15 @@ __metadata: languageName: node linkType: hard +"@brightchain/brightdate@npm:^0.36.0": + version: 0.36.0 + resolution: "@brightchain/brightdate@npm:0.36.0" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/6955ffe1631f3f7d1760527c1958aa40191e510d516e92f5c1b08528f14c37cf86b3fb42efb008bac36ae5bec5470f63c6515acf76f6a8046a5f1ccec855509f + languageName: node + linkType: hard + "@brightvision/remote@workspace:apps/remote": version: 0.0.0-use.local resolution: "@brightvision/remote@workspace:apps/remote" @@ -1394,6 +1403,7 @@ __metadata: version: 0.0.0-use.local resolution: "@brightvision/test-lab@workspace:apps/test-lab" dependencies: + "@brightvision/vision-client": "workspace:*" "@emotion/react": "npm:^11.13.0" "@emotion/styled": "npm:^11.13.0" "@mui/icons-material": "npm:^6.1.0" @@ -1416,6 +1426,7 @@ __metadata: version: 0.0.0-use.local resolution: "@brightvision/vision-client@workspace:packages/vision-client" dependencies: + "@brightchain/brightdate": "npm:^0.36.0" typescript: "npm:^5.5.4" vitest: "npm:^2.1.0" languageName: unknown @@ -11719,7 +11730,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1": +"tslib@npm:^2.0.1, tslib@npm:^2.3.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62