feat: file-backed goal objectives + compaction accounting fixes
Compaction fixes (observed in a real long run): - the summary message's zeroed tokens froze the goal counter at its pre-compaction value; segments now close with the previously displayed total as a continuity floor - audits and continuations after a summary tail now take execution params (provider/model/agent/variant) from the newest non-summary assistant turn instead of inheriting agent 'compaction' and the summarize model File-backed objectives: - the objective text lives in <data-dir>/goals/<sessionId>.md, keyed by session id (one goal per session, a new goal overwrites the file); metadata carries only an objectiveFile flag so session.updated fanout stays light, and never a path — ids are pattern-validated before any filesystem access - limit raised to 5000 chars, no snapshot field: the UI fetches content via PUT/GET/DELETE /api/goals/objective/:sessionId (behind the blanket /api auth gate), writes the file before stamping metadata, and falls back to an inline objective when the write fails - the loop reads the file fresh on every tick, so objectives are live-editable mid-goal; a missing file falls back to the inline text - scheduled goal tasks write the objective file server-side; VS Code degrades to the audit note (route unavailable there by design)
This commit is contained in:
@@ -10,7 +10,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import {
|
||||
formatGoalTokens,
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
@@ -32,6 +32,7 @@ interface SessionGoalDialogProps {
|
||||
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { goal } = useSessionGoal(sessionId, directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId, goal);
|
||||
|
||||
const [objective, setObjective] = React.useState('');
|
||||
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
|
||||
@@ -40,7 +41,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setObjective(goal?.objective ?? '');
|
||||
setObjective(goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? ''));
|
||||
setBudgetEnabled(Boolean(goal?.tokenBudget));
|
||||
setTokenBudget(goal?.tokenBudget ?? 200_000);
|
||||
// Seed the form only when the dialog opens; live goal updates while it is
|
||||
@@ -48,6 +49,14 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// File-backed objectives fetch async — the content usually lands right
|
||||
// after the dialog opens. Late-seed the textarea only while it is still
|
||||
// untouched so a slow fetch never clobbers the user's typing.
|
||||
React.useEffect(() => {
|
||||
if (!open || !goal?.objectiveFile || objectiveContent === null) return;
|
||||
setObjective((current) => (current === '' ? objectiveContent : current));
|
||||
}, [open, goal?.objectiveFile, objectiveContent]);
|
||||
|
||||
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -62,7 +71,8 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
}, [onOpenChange, t]);
|
||||
|
||||
const trimmedObjective = objective.trim();
|
||||
const objectiveChanged = trimmedObjective !== (goal?.objective ?? '');
|
||||
const savedObjective = goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? '');
|
||||
const objectiveChanged = trimmedObjective !== savedObjective;
|
||||
const budgetValue = budgetEnabled ? tokenBudget : null;
|
||||
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
|
||||
// A completed goal is read-only: remove it and arm a new one instead of
|
||||
@@ -112,7 +122,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
|
||||
)}
|
||||
|
||||
{isCompleted ? (
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{goal.objective}</p>
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{objectiveContent ?? goal.objective}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
|
||||
@@ -22,6 +22,7 @@ interface SessionGoalRowProps {
|
||||
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId ?? '', goal);
|
||||
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
@@ -65,11 +66,11 @@ export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessi
|
||||
className,
|
||||
)}
|
||||
aria-label={t('chat.goal.row.aria')}
|
||||
title={goal.objective}
|
||||
title={objectiveContent ?? undefined}
|
||||
>
|
||||
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
|
||||
{goal.note || goal.objective}
|
||||
{goal.note || objectiveContent || ''}
|
||||
</span>
|
||||
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
|
||||
// The agent stopped but the goal is still active: the server is
|
||||
|
||||
Reference in New Issue
Block a user