File size: 6,007 Bytes
3d700dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor";
import { useModelStore, SeededSwitch } from "#/stores/model-store";
import {
  getStoredConversationMetadata,
  mergeStoredConversationMetadata,
} from "#/api/conversation-metadata-store";
import { OpenHandsEvent } from "#/types/agent-server/core";
import { isSwitchLLMObservationEvent } from "#/types/agent-server/type-guards";
import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event";

export function recordModelSwitchMessage(
  conversationId: string,
  profileName: string,
  anchorEventId: string | null = getLastRenderableEventId(),
) {
  useModelStore
    .getState()
    .recordSwitch(conversationId, anchorEventId, profileName);
}

/**
 * The active-profile stamp for a conversation: the optimistic in-memory entry
 * (read first by the chat-input pill) plus the persisted per-conversation
 * `active_profile` metadata (survives reloads, round-tripped onto the
 * conversation by the agent-server adapter — issue #1082). Every path that
 * learns of a successful switch — the user `/model` mutation, the live
 * WebSocket handler, and the history seed below — must write through this one
 * function so the live and reload paths can't drift.
 *
 * `stampedAt` is persisted alongside the profile so the history seed can tell
 * a stale observation apart from a newer manual switch (see below). The live
 * WebSocket handler passes the observation's own (server) timestamp; the user
 * `/model` mutation has no event and defaults to the client clock.
 */
export function stampActiveLlmProfile(
  conversationId: string,
  profileName: string,
  stampedAt: string = new Date().toISOString(),
) {
  useModelStore.getState().setActiveProfile(conversationId, profileName);

  // Merge rather than replace: the stamp only owns these two fields, and the
  // record carries others it must not drop — the attached workspace mode
  // (#15520), the plugins snapshot behind the in-conversation plugins view,
  // and the local planner conversation id. Enumerating them here meant every
  // new field had to remember to opt in.
  mergeStoredConversationMetadata(conversationId, {
    active_profile: profileName,
    stamped_at: stampedAt,
  });
}

/**
 * Rebuilds the inline "Switched to" messages for a conversation from its loaded
 * history.
 *
 * The live messages live in an in-memory store written only by the WebSocket
 * handler and the user `/model` action. Existing conversations load via REST
 * history, which bypasses that handler, so without this replay no past agent
 * switches would render after a reload (the SwitchLLMObservation events are
 * also hidden as cards by `shouldRenderEvent`).
 *
 * `uiEvents` MUST be the event store's `uiEvents` (the same list the renderer
 * and the live `getLastRenderableEventId()` use) — NOT the raw history. The
 * renderer anchors a message after an event only if that event's id is in
 * `uiEvents.filter(shouldRenderEvent)`, and `uiEvents` differs from raw history:
 * actions are replaced by their observations and `ThinkObservation` /
 * `FinishObservation` are dropped. Anchoring off raw history would point at ids
 * that never mount (e.g. a dropped `ThinkObservation`), orphaning the message.
 *
 * Each successful switch is anchored to the last renderable event before it,
 * matching where the live handler would have placed it. Idempotent: entries are
 * keyed by the observation event id, so re-seeding on every reload is a no-op.
 *
 * Also re-derives the active-profile stamp from the latest successful
 * SwitchLLM observation. The stamp is otherwise written only by the live
 * WebSocket handler and the user `/model` mutation, so a switch that fired
 * while the socket was down would never be stamped — after a reload the pill
 * showed the stale profile while the panel showed the server's true model.
 *
 * The re-stamp is gated on the stored `stamped_at`: manual `/model` switches
 * go through REST and leave NO observation in history, so an unconditional
 * re-stamp would roll a newer manual switch back to an older tool switch.
 * The seed therefore only stamps when there is no existing stamp, the stamp
 * predates this field (legacy — treated as older than any observation so it
 * still gets repaired), or the latest observation is strictly newer.
 * Observation timestamps are server-generated while `stamped_at` from the
 * mutation path is client-generated, so the cross-clock comparison is a
 * heuristic — acceptable here since the alternative is a silent rollback.
 */
export function seedModelSwitchesFromHistory(
  conversationId: string,
  uiEvents: OpenHandsEvent[],
) {
  const switches: SeededSwitch[] = [];
  let lastRenderableId: string | null = null;
  let latestSwitch: { profileName: string; timestamp: string } | null = null;

  for (const event of uiEvents) {
    if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) {
      switches.push({
        id: `history-switch:${event.id}`,
        anchorEventId: lastRenderableId,
        profileName: event.observation.profile_name,
      });
      latestSwitch = {
        profileName: event.observation.profile_name,
        timestamp: event.timestamp,
      };
    }
    if (shouldRenderEvent(event)) {
      lastRenderableId = String(event.id);
    }
  }

  if (switches.length > 0) {
    useModelStore.getState().seedSwitches(conversationId, switches);
  }

  if (latestSwitch) {
    const { profileName, timestamp } = latestSwitch;
    const stored = getStoredConversationMetadata(conversationId);
    const stampedAt = stored?.active_profile ? stored.stamped_at : null;
    if (!stampedAt || Date.parse(timestamp) > Date.parse(stampedAt)) {
      // Stamp with the observation's own timestamp so re-seeding the same
      // history on the next reload is a no-op (strictly-newer never matches).
      stampActiveLlmProfile(conversationId, profileName, timestamp);
    }
  }
}