Stop idle goal polling

Publish goal revisions through the shared state monitor and refresh the Goal UI only when the selected chat or goal state changes.

Keep the local elapsed-time clock, remove recurring API requests, and cover the state-push contract with focused regressions.
This commit is contained in:
Alessandro 2026-08-11 23:52:38 +02:00
parent 4f8ae9cc2c
commit e8e566d221
6 changed files with 95 additions and 13 deletions

View file

@ -27,6 +27,9 @@
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
- While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
- Goal state changes publish a context revision through the shared state-push
lifecycle; the WebUI refreshes on context or revision changes and never polls
the Goal API while idle.
## Work Guidance

View file

@ -0,0 +1,16 @@
import { store as goalStore } from "/plugins/_goal/webui/goal-store.js";
let lastContextId = "";
let lastRevision = null;
export default async function refreshGoalOnRevision(ctx) {
const snapshot = ctx?.snapshot;
const contextId = String(snapshot?.context || "");
const activeContext = (snapshot?.contexts || []).find(item => item?.id === contextId);
const revision = activeContext?._goal_revision ?? null;
if (contextId === lastContextId && revision === lastRevision) return;
lastContextId = contextId;
lastRevision = revision;
await goalStore.refresh(true);
}

View file

@ -5,7 +5,6 @@
<div x-data
class="goal-strip-root"
x-create="$store.goalBar.onMount()"
x-init="$watch('$store.chats.selected', () => $store.goalBar.refresh(true))"
x-destroy="$store.goalBar.cleanup()">
<template x-if="$store.goalBar && $store.goalBar.visible">
<div class="goal-strip" :class="`is-${$store.goalBar.goal?.status || 'active'}`">

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -57,6 +58,56 @@ def test_goal_storage_round_trip(context_id: str):
assert goal.get_goal(context_id) is None
def test_goal_changes_publish_state_revision(context_id: str, monkeypatch):
from agent import AgentContext
from helpers import state_monitor_integration
revisions = iter([1.0, 2.0, 3.0])
output_data = {}
dirty = []
context = SimpleNamespace(
set_output_data=lambda key, value: output_data.__setitem__(key, value)
)
monkeypatch.setattr(AgentContext, "get", lambda _context_id: context)
monkeypatch.setattr(goal.time, "time", lambda: next(revisions))
monkeypatch.setattr(
state_monitor_integration,
"mark_dirty_for_context",
lambda context_id, *, reason: dirty.append((context_id, reason)),
)
goal.create_goal(context_id, "Publish changes")
goal.update_goal(context_id, status="paused")
goal.delete_goal(context_id)
assert output_data["_goal_revision"] == 3.0
assert dirty == [(context_id, "plugins._goal")] * 3
def test_goal_webui_uses_state_revisions_instead_of_polling():
plugin_root = Path(__file__).resolve().parents[1]
store = (plugin_root / "webui" / "goal-store.js").read_text()
strip = (
plugin_root
/ "extensions"
/ "webui"
/ "chat-input-progress-start"
/ "goal-strip.html"
).read_text()
refresh = (
plugin_root
/ "extensions"
/ "webui"
/ "apply_snapshot_before"
/ "refresh-goal.js"
).read_text()
assert "setInterval(() => this.refresh" not in store
assert "$watch('$store.chats.selected'" not in strip
assert "_goal_revision" in refresh
assert "goalStore.refresh(true)" in refresh
def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
assert created["effects"][0]["message"] == "Goal set."

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import json
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@ -141,7 +142,9 @@ def update_goal(
def delete_goal(context_id: str) -> None:
files.delete_file(_goal_path(_require_context_id(context_id)))
context_id = _require_context_id(context_id)
files.delete_file(_goal_path(context_id))
_notify_goal_changed(context_id)
def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
@ -182,6 +185,23 @@ def _write_goal(goal: dict[str, Any]) -> None:
_goal_path(str(goal["context_id"])),
json.dumps(public_goal(goal), indent=2, ensure_ascii=False) + "\n",
)
_notify_goal_changed(str(goal["context_id"]))
def _notify_goal_changed(context_id: str) -> None:
from agent import AgentContext
context = AgentContext.get(context_id)
if context is None:
return
context.set_output_data("_goal_revision", time.time())
try:
from helpers.state_monitor_integration import mark_dirty_for_context
mark_dirty_for_context(context_id, reason="plugins._goal")
except Exception:
pass
def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:

View file

@ -15,7 +15,6 @@ const model = {
editing: false,
draft: "",
lastContextId: "",
intervalId: null,
clockIntervalId: null,
goalChangedHandler: null,
now: Date.now(),
@ -82,17 +81,15 @@ const model = {
document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
this.goalChangedHandler = (event) => {
const detail = event?.detail || {};
if (detail.goal === null) {
this.goal = null;
}
void this.refresh(true);
if (detail.context_id && detail.context_id !== this.contextId) return;
this.goal = detail.goal || null;
this.now = Date.now();
if (!this.goal) this.editing = false;
};
window.addEventListener("goal:changed", this.goalChangedHandler);
this.clockIntervalId = window.setInterval(() => {
this.now = Date.now();
}, 1000);
this.intervalId = window.setInterval(() => this.refresh(), 3000);
void this.refresh(true);
},
cleanup() {
@ -100,14 +97,10 @@ const model = {
if (this.goalChangedHandler) {
window.removeEventListener("goal:changed", this.goalChangedHandler);
}
if (this.intervalId) {
window.clearInterval(this.intervalId);
}
if (this.clockIntervalId) {
window.clearInterval(this.clockIntervalId);
}
this.goalChangedHandler = null;
this.intervalId = null;
this.clockIntervalId = null;
},