mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 20:52:23 +02:00
[eric] apps: auto hard-reload the app card when a turn installed deps (soft reload can't see new packages)
This commit is contained in:
@@ -103,6 +103,14 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex
|
||||
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
|
||||
except Exception:
|
||||
pass
|
||||
if installed_pkg:
|
||||
# Tell the app card this turn changed deps so its turn-finish reload restarts Vite; a soft webview reload can't pick up newly installed packages.
|
||||
try:
|
||||
await ws_manager.send_to_session(session.id, "agent:app_deps_changed", {
|
||||
"session_id": session.id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
elif wrote_files:
|
||||
if file_path:
|
||||
try:
|
||||
|
||||
@@ -65,3 +65,35 @@ async def test_agent_tool_spawns_subsession_into_live_registry():
|
||||
assert child.parent_session_id == parent_id
|
||||
assert child.active_mcps == [] # context-isolation invariant: no inherited activations
|
||||
assert "sub-agent did the work" in str(child.messages[-1].content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_builder_dep_install_broadcasts_app_deps_changed():
|
||||
"""An npm install in a view-builder session must tell the app card this turn
|
||||
changed deps (agent:app_deps_changed), so its turn-finish reload restarts Vite
|
||||
instead of soft-reloading a preview that can't see the new packages."""
|
||||
registry: dict = {}
|
||||
ctx = p_ctx(registry)
|
||||
ctx.session.mode = "view-builder"
|
||||
with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send:
|
||||
await tool_result_hook.post_tool_hook(
|
||||
ctx, {"tool_name": "Bash", "tool_response": "added 3 packages",
|
||||
"tool_input": {"command": "npm install recharts"}}, "tu1", None
|
||||
)
|
||||
events = [c.args[1] for c in send.await_args_list]
|
||||
assert "agent:app_deps_changed" in events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_builder_plain_write_does_not_flag_deps_changed():
|
||||
"""A plain file edit must NOT escalate the reload; only dep changes do."""
|
||||
registry: dict = {}
|
||||
ctx = p_ctx(registry)
|
||||
ctx.session.mode = "view-builder"
|
||||
with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send:
|
||||
await tool_result_hook.post_tool_hook(
|
||||
ctx, {"tool_name": "Write", "tool_response": "ok",
|
||||
"tool_input": {"file_path": "/ws/frontend/src/App.tsx", "content": "x"}}, "tu1", None
|
||||
)
|
||||
events = [c.args[1] for c in send.await_args_list]
|
||||
assert "agent:app_deps_changed" not in events
|
||||
|
||||
@@ -179,10 +179,30 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const wasBuildingRef = useRef(false);
|
||||
const finishTimerRef = useRef<number | null>(null);
|
||||
// Whether this turn changed deps (needs a Vite restart, not just a soft reload). Held in a ref so the reload effect stays keyed on the status transition alone.
|
||||
const depsChanged = useAppSelector(
|
||||
(s) => (output.session_id ? !!s.agents.sessions[output.session_id]?.app_deps_changed : false),
|
||||
);
|
||||
const depsChangedRef = useRef(false);
|
||||
useEffect(() => { depsChangedRef.current = depsChanged; }, [depsChanged]);
|
||||
useEffect(() => {
|
||||
const building = linkedStatus === 'running' || linkedStatus === 'waiting_approval';
|
||||
if (wasBuildingRef.current && !building) {
|
||||
previewRef.current?.reload();
|
||||
const wsId = output.workspace_id;
|
||||
if (depsChangedRef.current && wsId) {
|
||||
// Deps changed this turn: a soft reload can't pick up new packages, so restart the Vite runtime first, then reload.
|
||||
void (async () => {
|
||||
try {
|
||||
const tok = getAuthToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (tok) headers.Authorization = `Bearer ${tok}`;
|
||||
await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, { method: 'POST', headers });
|
||||
} catch { /* failures surface via the runtime log WS */ }
|
||||
previewRef.current?.reload();
|
||||
})();
|
||||
} else {
|
||||
previewRef.current?.reload();
|
||||
}
|
||||
setFinishing(true);
|
||||
if (finishTimerRef.current) clearTimeout(finishTimerRef.current);
|
||||
finishTimerRef.current = window.setTimeout(() => setFinishing(false), 1200);
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface AgentSession {
|
||||
context_overflow?: { reason: string; message: string; at: string } | null;
|
||||
rate_limited?: { retry_after_s: number | null; at: string } | null;
|
||||
context_recovered?: { at: string } | null;
|
||||
// Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts.
|
||||
app_deps_changed?: boolean;
|
||||
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
|
||||
mcp_suggestions_is_vague?: boolean;
|
||||
compacted_through_msg_id?: string | null;
|
||||
@@ -721,6 +723,10 @@ const agentsSlice = createSlice({
|
||||
if (terminal.includes(session.status as any) && action.payload.status === 'running') {
|
||||
return;
|
||||
}
|
||||
// A fresh turn clears last turn's deps-changed flag so the app card only hard-reloads for the turn that actually changed deps.
|
||||
if (action.payload.status === 'running' && session.status !== 'running') {
|
||||
session.app_deps_changed = false;
|
||||
}
|
||||
session.status = action.payload.status;
|
||||
}
|
||||
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) {
|
||||
@@ -960,6 +966,11 @@ const agentsSlice = createSlice({
|
||||
if (session) session.rate_limited = null;
|
||||
},
|
||||
|
||||
setAppDepsChanged(state, action: PayloadAction<{ sessionId: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.app_deps_changed = true;
|
||||
},
|
||||
|
||||
setContextRecovered(state, action: PayloadAction<{ sessionId: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.context_recovered = { at: new Date().toISOString() };
|
||||
@@ -1453,6 +1464,7 @@ export const {
|
||||
clearRateLimited,
|
||||
setContextRecovered,
|
||||
clearContextRecovered,
|
||||
setAppDepsChanged,
|
||||
clearContextOverflow,
|
||||
setMcpSuggestions,
|
||||
clearMcpSuggestions,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
setContextOverflow,
|
||||
setRateLimited,
|
||||
setContextRecovered,
|
||||
setAppDepsChanged,
|
||||
setMcpSuggestions,
|
||||
addBranch,
|
||||
setActiveBranch,
|
||||
@@ -562,6 +563,13 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:app_deps_changed':
|
||||
// A view-builder agent installed/changed deps this turn; the app card escalates its turn-finish reload from soft to a Vite restart so new deps actually load.
|
||||
if (session_id) {
|
||||
store.dispatch(setAppDepsChanged({ sessionId: session_id }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:context_status':
|
||||
// Auto-compaction collapsed older turns into a summary. Mirror compacted_through_msg_id locally so the renderer can drop a visible "N earlier turns summarized" chip into the transcript. Other reasons (cleared, etc.) flow through this same event but don't currently need a chip, ignore them for now.
|
||||
if (session_id && data.reason === 'compacted') {
|
||||
|
||||
Reference in New Issue
Block a user