mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(kanban): task effort estimate via the auxiliary model
An "Estimate" action asks the auto-routed auxiliary model for a rough token
count + complexity band (S/M/L) with a one-line rationale — tokens, not
dollars, since providers don't report cost reliably. POST /estimate (typed
title/body, for the create dialog) and POST /tasks/{id}/estimate (existing
cards) share one core. Desktop renders it inline ("~15k tok · Medium") with a
"makes a model call" disclaimer; SDK exports compactNumber.
This commit is contained in:
parent
027ef381a4
commit
346149c4f8
8 changed files with 405 additions and 22 deletions
|
|
@ -20,6 +20,7 @@ import type {
|
|||
KanbanTask,
|
||||
KanbanTaskDetail,
|
||||
OrchestrationSettings,
|
||||
TaskEstimate,
|
||||
WorkerLog
|
||||
} from './types'
|
||||
|
||||
|
|
@ -203,6 +204,15 @@ export const createBoard = (slug: string, name: string, projectId?: string) =>
|
|||
body: { slug, name, ...(projectId ? { project_id: projectId } : {}) }
|
||||
})
|
||||
|
||||
/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a
|
||||
* model call — gate behind an explicit user action + disclaimer. */
|
||||
export const estimateTask = (id: string) =>
|
||||
call<TaskEstimate>(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} })
|
||||
|
||||
/** Estimate from typed title/body before a task exists (create dialog). */
|
||||
export const estimateNew = (title: string, body: string) =>
|
||||
call<TaskEstimate>('/estimate', { method: 'POST', body: { title, body: body || undefined } })
|
||||
|
||||
/** Edit a board's display metadata + default project directory. Pass
|
||||
* `default_workdir: ''` to clear it. Slug is immutable. */
|
||||
export const updateBoard = (slug: string, patch: Record<string, unknown>) =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
compactNumber,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
|
|
@ -68,6 +69,7 @@ import {
|
|||
bulkTasks,
|
||||
createTask,
|
||||
deleteTask,
|
||||
estimateNew,
|
||||
fetchBoard,
|
||||
fetchBoards,
|
||||
fetchProfiles,
|
||||
|
|
@ -77,7 +79,7 @@ import {
|
|||
import { BoardSwitcher } from './board-switcher'
|
||||
import { TaskDrawer } from './drawer'
|
||||
import { OrchestrationPanel } from './orchestration'
|
||||
import { columnMeta, type KanbanBoard, type KanbanTask } from './types'
|
||||
import { columnMeta, COMPLEXITY_LABEL, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
|
||||
import {
|
||||
ago,
|
||||
ARC_TITLES,
|
||||
|
|
@ -539,9 +541,10 @@ function NewTaskDialog({
|
|||
// unassigned — parking a card is the explicit choice, not the default.
|
||||
const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default'
|
||||
|
||||
// Board-level workspace default: a task inherits the current board's project
|
||||
// workspace (scratch when unscoped, worktree in a git repo, else dir) unless
|
||||
// overridden below. Set the board's project in the switcher's "Board settings…".
|
||||
// Board-level workspace default: a task inherits the current board's
|
||||
// configured project dir (scratch when unset, worktree in a git repo, else
|
||||
// dir) unless the operator overrides it below. Set the board default in the
|
||||
// board switcher's "Board settings…".
|
||||
const selectedSlug = useValue($boardSlug)
|
||||
const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 })
|
||||
const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current))
|
||||
|
|
@ -555,13 +558,28 @@ function NewTaskDialog({
|
|||
const [priority, setPriority] = useState('0')
|
||||
const [skills, setSkills] = useState('')
|
||||
const [workspaceKind, setWorkspaceKind] = useState<string>(boardDefaultKind)
|
||||
// Empty = inherit the board's project dir (backend resolves it); a path here
|
||||
// overrides just this task. Only meaningful for dir/worktree.
|
||||
// Empty = inherit the board's default project dir (backend resolves it);
|
||||
// a path here overrides just this task. Only meaningful for dir/worktree.
|
||||
const [workspacePath, setWorkspacePath] = useState('')
|
||||
const [parent, setParent] = useState('')
|
||||
const [goalMode, setGoalMode] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const [estimate, setEstimate] = useState<null | TaskEstimate>(null)
|
||||
|
||||
// Rough effort estimate from the typed title/body (before the task exists),
|
||||
// via the auto-routed auxiliary model. Makes a model call — explicit action.
|
||||
const estMut = useMutation({
|
||||
mutationFn: () => estimateNew(title.trim(), bodyText.trim()),
|
||||
onError: err => host.notify({ kind: 'error', message: errText(err) }),
|
||||
onSuccess: r => {
|
||||
if (r.ok) {
|
||||
setEstimate(r)
|
||||
} else {
|
||||
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reset per open — the dialog is externally controlled (open = target set),
|
||||
// so onOpenChange(true) never fires; key the reset off `target` (and the
|
||||
|
|
@ -579,6 +597,7 @@ function NewTaskDialog({
|
|||
setGoalMode(false)
|
||||
setError(null)
|
||||
setBusy(false)
|
||||
setEstimate(null)
|
||||
}
|
||||
}, [target, boardDefaultKind])
|
||||
|
||||
|
|
@ -610,7 +629,7 @@ function NewTaskDialog({
|
|||
title: trimmed,
|
||||
triage: isTriage,
|
||||
workspace_kind: workspaceKind,
|
||||
// Empty → backend inherits the board's project dir.
|
||||
// Empty → backend inherits the board's default project dir.
|
||||
workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined
|
||||
})
|
||||
|
||||
|
|
@ -743,6 +762,41 @@ function NewTaskDialog({
|
|||
{error && <span className="text-[0.75rem] text-destructive">{error}</span>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="mr-auto flex items-center gap-1 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
{estimate?.ok ? (
|
||||
<>
|
||||
<Tip label={estimate.rationale || 'Rough estimate'}>
|
||||
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
|
||||
~{compactNumber(estimate.est_tokens)} tok
|
||||
{estimate.complexity ? ` · ${COMPLEXITY_LABEL[estimate.complexity] ?? estimate.complexity}` : ''}
|
||||
</span>
|
||||
</Tip>
|
||||
<Tip label="Re-estimate">
|
||||
<Button
|
||||
aria-label="Re-estimate"
|
||||
disabled={!title.trim() || estMut.isPending}
|
||||
onClick={() => estMut.mutate()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.7rem" spinning={estMut.isPending} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
) : (
|
||||
<Tip label="Rough token + complexity estimate from the auxiliary model — makes a model call.">
|
||||
<Button
|
||||
disabled={!title.trim() || estMut.isPending}
|
||||
onClick={() => estMut.mutate()}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={estMut.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={estMut.isPending} />
|
||||
{estMut.isPending ? 'Estimating…' : 'Estimate'}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={onClose} variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
compactNumber,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
Loader,
|
||||
LogView,
|
||||
Textarea,
|
||||
Tip,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
|
|
@ -31,6 +33,7 @@ import {
|
|||
$boardSlug,
|
||||
addComment,
|
||||
deleteTask,
|
||||
estimateTask,
|
||||
fetchLog,
|
||||
fetchProfiles,
|
||||
fetchTask,
|
||||
|
|
@ -44,12 +47,14 @@ import {
|
|||
} from './api'
|
||||
import {
|
||||
columnMeta,
|
||||
COMPLEXITY_LABEL,
|
||||
type Diagnostic,
|
||||
type DiagnosticAction,
|
||||
type KanbanAttachment,
|
||||
type KanbanEvent,
|
||||
type KanbanTaskDetail,
|
||||
SEVERITY_TONE
|
||||
SEVERITY_TONE,
|
||||
type TaskEstimate
|
||||
} from './types'
|
||||
import {
|
||||
ago,
|
||||
|
|
@ -426,6 +431,73 @@ function AttachmentsSection({
|
|||
)
|
||||
}
|
||||
|
||||
// Rough effort estimate via the auxiliary (auto-routed) model. Tokens +
|
||||
// complexity, never dollars — providers don't report cost reliably. Gated
|
||||
// behind an explicit click + disclaimer since it makes a model call. The
|
||||
// control keeps a stable footprint (spinner swaps in place) so nothing jumps.
|
||||
function EstimateSection({ id }: { id: string }) {
|
||||
const [result, setResult] = useState<null | TaskEstimate>(null)
|
||||
|
||||
const est = useMutation({
|
||||
mutationFn: () => estimateTask(id),
|
||||
onError: err => host.notify({ kind: 'error', message: errText(err) }),
|
||||
onSuccess: r => {
|
||||
if (r.ok) {
|
||||
setResult(r)
|
||||
} else {
|
||||
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A new task resets the cached estimate (the drawer reuses one instance).
|
||||
useEffect(() => setResult(null), [id])
|
||||
|
||||
return (
|
||||
<Section label="Estimate">
|
||||
{result?.ok ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-[0.8125rem]">
|
||||
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
|
||||
~{compactNumber(result.est_tokens)} tok
|
||||
</span>
|
||||
{result.complexity && (
|
||||
<span className="text-(--ui-text-tertiary)">
|
||||
· {COMPLEXITY_LABEL[result.complexity] ?? result.complexity}
|
||||
</span>
|
||||
)}
|
||||
<Tip label="Re-estimate">
|
||||
<Button
|
||||
aria-label="Re-estimate"
|
||||
className="ml-auto"
|
||||
disabled={est.isPending}
|
||||
onClick={() => est.mutate()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.75rem" spinning={est.isPending} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
{result.rationale && (
|
||||
<p className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">{result.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={est.isPending} onClick={() => est.mutate()} size="xs" variant="outline">
|
||||
<Codicon name={est.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={est.isPending} />
|
||||
{est.isPending ? 'Estimating…' : 'Estimate effort'}
|
||||
</Button>
|
||||
<Tip label="Runs a quick auxiliary-model call to estimate tokens + complexity. A rough guide, not a bill.">
|
||||
<span className="text-[0.625rem] text-(--ui-text-quaternary)">makes a model call</span>
|
||||
</Tip>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskDrawer({
|
||||
columns,
|
||||
id,
|
||||
|
|
@ -666,6 +738,8 @@ export function TaskDrawer({
|
|||
|
||||
<DescriptionSection body={task.body} onSave={body => void mutate(() => patchTask(task.id, { body }))()} />
|
||||
|
||||
<EstimateSection id={task.id} />
|
||||
|
||||
{task.result && (
|
||||
<Section label="Result">
|
||||
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
STATUSBAR_AREAS,
|
||||
Tip,
|
||||
useQuery,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
|
|
@ -55,18 +56,19 @@ function KanbanCount() {
|
|||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={() => host.navigate('/kanban')}
|
||||
title={`Kanban — ${count('running')} running, ${count('ready')} ready`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="project" size="0.7rem" />
|
||||
<span>{active}</span>
|
||||
</button>
|
||||
<Tip label={`Kanban — ${count('running')} running, ${count('ready')} ready`}>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={() => host.navigate('/kanban')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="project" size="0.7rem" />
|
||||
<span>{active}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,18 @@ export interface KanbanProject {
|
|||
color?: null | string
|
||||
}
|
||||
|
||||
/** POST /tasks/:id/estimate — rough auxiliary-model estimate (never dollars). */
|
||||
export interface TaskEstimate {
|
||||
ok: boolean
|
||||
reason?: null | string
|
||||
est_tokens?: number
|
||||
complexity?: 'L' | 'M' | 'S' | null
|
||||
rationale?: null | string
|
||||
model?: null | string
|
||||
}
|
||||
|
||||
/** Human-readable complexity band (the backend returns the compact letter). */
|
||||
export const COMPLEXITY_LABEL: Record<string, string> = { L: 'Large', M: 'Medium', S: 'Small' }
|
||||
|
||||
export interface BoardsResponse {
|
||||
boards: BoardMeta[]
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ export {
|
|||
useI18n,
|
||||
usePluginI18n
|
||||
} from '@/i18n'
|
||||
/** THE compact-number formatter — every user-facing count/token figure goes
|
||||
* through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */
|
||||
export { compactNumber } from '@/lib/format'
|
||||
export { triggerHaptic as haptic } from '@/lib/haptics'
|
||||
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
|
||||
export * as icons from '@/lib/icons'
|
||||
|
|
@ -237,8 +240,6 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left'
|
|||
* setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll
|
||||
* readiness from raw RPC shapes. */
|
||||
export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
/** Canonical time formatting — every timestamp/age string in the app comes
|
||||
* from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */
|
||||
export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time'
|
||||
export { cn } from '@/lib/utils'
|
||||
export { THEMES_AREA } from '@/themes/user-themes'
|
||||
|
|
|
|||
|
|
@ -1740,6 +1740,134 @@ def reassign_task_endpoint(
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Estimate — a rough token/complexity estimate for a task via the auxiliary
|
||||
# (auto-routed) model. NOT a dollar cost: providers don't report cost
|
||||
# reliably, so we estimate tokens + a complexity band with a one-line why.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ESTIMATE_SYSTEM_PROMPT = (
|
||||
"You estimate how much work an autonomous coding agent will spend on a "
|
||||
"kanban task. Given the task title and description, respond with STRICT "
|
||||
"JSON only (no prose, no code fence):\n"
|
||||
'{"est_tokens": <integer total tokens across the whole run>, '
|
||||
'"complexity": "S"|"M"|"L", '
|
||||
'"rationale": "<one short sentence>"}\n'
|
||||
"Base the token figure on a realistic multi-turn agent run (reading files, "
|
||||
"tool calls, edits, retries) — not a single reply. S≈small/localized, "
|
||||
"M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess."
|
||||
)
|
||||
|
||||
|
||||
class EstimateBody(BaseModel):
|
||||
title: str = ""
|
||||
body: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/estimate")
|
||||
def estimate_text_endpoint(payload: EstimateBody):
|
||||
"""Estimate from raw title/body — used by the create dialog before a task
|
||||
exists yet. Same outcome shape as the per-task endpoint below."""
|
||||
return _run_estimate(payload.title, payload.body)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/estimate")
|
||||
def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
|
||||
"""Rough token + complexity estimate for an existing task via the auxiliary
|
||||
model. Returns ``{ok, est_tokens, complexity, rationale, model}``; a non-OK
|
||||
outcome is NOT an HTTP error. Runs in FastAPI's threadpool (sync ``def``)
|
||||
because the LLM call can take several seconds.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return _run_estimate(task.title, task.body)
|
||||
|
||||
|
||||
def _run_estimate(title: str, body: Optional[str]) -> dict:
|
||||
"""Shared estimate core: ask the auto-routed auxiliary model for a rough
|
||||
token + complexity read on a task described by ``title``/``body``.
|
||||
|
||||
Never raises — a bad config / parse / API error becomes
|
||||
``{"ok": False, "reason": ...}`` so the UI can render it inline.
|
||||
"""
|
||||
if not (title or "").strip():
|
||||
return {"ok": False, "reason": "a title is required to estimate"}
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
except Exception:
|
||||
return {"ok": False, "reason": "auxiliary client unavailable"}
|
||||
|
||||
def _cap(s: Optional[str], n: int) -> str:
|
||||
s = (s or "").strip()
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
user_msg = (
|
||||
f"Title: {_cap(title, 400)}\n\n"
|
||||
f"Description:\n{_cap(body, 4000) or '(none)'}"
|
||||
)
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="kanban_estimator",
|
||||
messages=[
|
||||
{"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.0,
|
||||
max_tokens=300,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
|
||||
|
||||
try:
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
model = getattr(resp, "model", None)
|
||||
except Exception:
|
||||
raw, model = "", None
|
||||
|
||||
# Reuse the same tolerant JSON-blob extraction the specifier uses.
|
||||
parsed: Optional[dict] = None
|
||||
try:
|
||||
import json as _json
|
||||
import re as _re
|
||||
blob = raw
|
||||
if not blob.lstrip().startswith("{"):
|
||||
m = _re.search(r"\{.*\}", blob, _re.DOTALL)
|
||||
blob = m.group(0) if m else blob
|
||||
obj = _json.loads(blob)
|
||||
if isinstance(obj, dict):
|
||||
parsed = obj
|
||||
except Exception:
|
||||
parsed = None
|
||||
|
||||
if not parsed:
|
||||
return {"ok": False, "reason": "could not parse an estimate from the model"}
|
||||
|
||||
try:
|
||||
est_tokens = int(parsed.get("est_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
est_tokens = 0
|
||||
complexity = str(parsed.get("complexity") or "").strip().upper()
|
||||
if complexity not in {"S", "M", "L"}:
|
||||
complexity = None
|
||||
rationale = str(parsed.get("rationale") or "").strip() or None
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"est_tokens": est_tokens,
|
||||
"complexity": complexity,
|
||||
"rationale": rationale,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
102
tests/plugins/test_kanban_estimate.py
Normal file
102
tests/plugins/test_kanban_estimate.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Kanban dashboard plugin: task effort estimate.
|
||||
|
||||
The estimate endpoints call the auto-routed auxiliary model and parse a
|
||||
compact JSON reply (tokens + complexity + rationale). Tests monkeypatch
|
||||
``call_llm`` so no network is touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_est_test", plugin_file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _fake_resp(content: str, model: str = "aux-mini"):
|
||||
msg = types.SimpleNamespace(content=content)
|
||||
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)], model=model)
|
||||
|
||||
|
||||
def test_estimate_parses_model_json(client, monkeypatch):
|
||||
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "big refactor"}).json()["task"]["id"]
|
||||
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
assert kwargs.get("task") == "kanban_estimator"
|
||||
return _fake_resp('{"est_tokens": 42000, "complexity": "M", "rationale": "multi-file edit"}')
|
||||
|
||||
monkeypatch.setattr(aux, "call_llm", fake_call_llm)
|
||||
|
||||
body = client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()
|
||||
assert body["ok"] is True
|
||||
assert body["est_tokens"] == 42000
|
||||
assert body["complexity"] == "M"
|
||||
assert body["rationale"] == "multi-file edit"
|
||||
assert body["model"] == "aux-mini"
|
||||
|
||||
|
||||
def test_estimate_tolerates_unparseable_reply(client, monkeypatch):
|
||||
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "vague"}).json()["task"]["id"]
|
||||
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(aux, "call_llm", lambda **kw: _fake_resp("I cannot estimate this, sorry."))
|
||||
|
||||
assert client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()["ok"] is False
|
||||
|
||||
|
||||
def test_estimate_unknown_task_404(client):
|
||||
assert client.post("/api/plugins/kanban/tasks/t_missing/estimate").status_code == 404
|
||||
|
||||
|
||||
def test_estimate_from_text_no_task(client, monkeypatch):
|
||||
"""The create dialog estimates from typed title/body before a task exists."""
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(
|
||||
aux, "call_llm",
|
||||
lambda **kw: _fake_resp('{"est_tokens": 8000, "complexity": "S", "rationale": "localized"}'),
|
||||
)
|
||||
body = client.post(
|
||||
"/api/plugins/kanban/estimate", json={"title": "tweak a label", "body": "in settings"}
|
||||
).json()
|
||||
assert body["ok"] is True
|
||||
assert body["est_tokens"] == 8000
|
||||
assert body["complexity"] == "S"
|
||||
|
||||
|
||||
def test_estimate_from_text_requires_title(client):
|
||||
assert client.post("/api/plugins/kanban/estimate", json={"title": " ", "body": "x"}).json()["ok"] is False
|
||||
Loading…
Add table
Add a link
Reference in a new issue