ai: persist agent-history + per-message attribution to localStorage

Reloading the page mid-conversation used to keep the messages (via
LIVE_KEY) but lose the two agent-tracking maps, so:
  - row signatures snapped back to whoever was currently active
  - the next turn after reload didn't include the PRIOR HAND-OFF
    block, even though the transcript clearly had multiple personas

Both maps are now stored alongside the live snapshot:
  AGENTS_KEY        crema.ai.agent-history   set of Agent
  MSG_AGENTS_KEY    crema.ai.message-agents  index -> Agent

Stored as JSON arrays since Maps don't serialize. Hydrated on mount
via useState lazy initializer, persisted on every change via two
useEffects, cleared in lockstep with LIVE_KEY when the operator
hits Clear conversation.

Reload mid-thread now reads identically to the pre-reload state:
- atlas» turns 1-3 stay attributed to atlas
- pythia» turn 4 stays attributed to pythia
- next turn after reload still carries the hand-off note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-02 20:04:55 +10:00
parent e4ed05b815
commit 20494d1620

View File

@@ -115,6 +115,65 @@ function saveLive(msgs: LLMMessage[]) {
// Quota exceeded or similar — silently drop persistence. // Quota exceeded or similar — silently drop persistence.
} }
} }
/* Per-message agent attribution + the set of agents that have produced
* turns in the current conversation. Persisted alongside LIVE_KEY so a
* reload mid-thread preserves both who-said-what and the hand-off note
* the next turn carries.
*
* Stored as plain JSON shapes (Maps don't serialize):
* AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory
* MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents
*/
const AGENTS_KEY = "crema.ai.agent-history"
const MSG_AGENTS_KEY = "crema.ai.message-agents"
function loadAgentHistory(): Map<string, Agent> {
if (typeof window === "undefined") return new Map()
try {
const raw = localStorage.getItem(AGENTS_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][])
} catch {}
return new Map()
}
function saveAgentHistory(m: Map<string, Agent>) {
if (typeof window === "undefined") return
if (m.size === 0) {
localStorage.removeItem(AGENTS_KEY)
return
}
try {
localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()]))
} catch {}
}
function loadMessageAgents(): Map<number, Agent> {
if (typeof window === "undefined") return new Map()
try {
const raw = localStorage.getItem(MSG_AGENTS_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][])
} catch {}
return new Map()
}
function saveMessageAgents(m: Map<number, Agent>) {
if (typeof window === "undefined") return
if (m.size === 0) {
localStorage.removeItem(MSG_AGENTS_KEY)
return
}
try {
localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()]))
} catch {}
}
function clearAgentMaps() {
if (typeof window === "undefined") return
localStorage.removeItem(AGENTS_KEY)
localStorage.removeItem(MSG_AGENTS_KEY)
}
function clearLive() { function clearLive() {
if (typeof window === "undefined") return if (typeof window === "undefined") return
localStorage.removeItem(LIVE_KEY) localStorage.removeItem(LIVE_KEY)
@@ -369,19 +428,29 @@ function ChatSurface({
// the new agent knows it's stepping into a transcript started by another // the new agent knows it's stepping into a transcript started by another
// persona — without that note it answers as if it produced every prior // persona — without that note it answers as if it produced every prior
// turn itself, which is jarring. // turn itself, which is jarring.
// Both maps are seeded from localStorage so a reload mid-thread keeps
// attribution + hand-off context intact. Persisted on every change via
// the effect lower down, cleared together with the live snapshot.
const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>( const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>(
() => new Map(), () => loadAgentHistory(),
) )
const prevAgentRef = useRef<Agent | undefined>(activeAgent) const prevAgentRef = useRef<Agent | undefined>(activeAgent)
// Per-message agent attribution: which agent produced the assistant // Per-message agent attribution: which agent produced the assistant
// message at each index. Populated when a turn finishes streaming, used // message at each index. Populated when a turn finishes streaming, used
// in MessageRow to show the right name in the signature line. Survives // in MessageRow to show the right name in the signature line.
// until the conversation is cleared.
const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>( const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>(
() => new Map(), () => loadMessageAgents(),
) )
// Persist whenever either map changes.
useEffect(() => {
saveAgentHistory(agentHistory)
}, [agentHistory])
useEffect(() => {
saveMessageAgents(messageAgents)
}, [messageAgents])
// Hand-off prompt — only emitted when this conversation has been touched // Hand-off prompt — only emitted when this conversation has been touched
// by more than one agent. Lists prior personas the new agent might see in // by more than one agent. Lists prior personas the new agent might see in
// the transcript. // the transcript.
@@ -439,6 +508,7 @@ function ChatSurface({
const resetAndClear = useCallback(() => { const resetAndClear = useCallback(() => {
initialLive.current = [] initialLive.current = []
clearLive() clearLive()
clearAgentMaps()
setMessages([]) setMessages([])
setAgentHistory(new Map()) setAgentHistory(new Map())
setMessageAgents(new Map()) setMessageAgents(new Map())