Watch
1
0
Fork
You've already forked souveraine
0

fix: keep the conversation when the agent list is re-polled

selectAgent cleared conversationId even when the agent hadn't changed, and the
60s inventory poll re-selects the persisted agent forever — so any message sent
a minute after the last one opened a fresh conversation. 27 of them yesterday,
four messages each. An empty curl body no longer counts as "no conversations".
This commit is contained in:
Fimeg 2026-07-31 12:50:24 -04:00
commit e4e659483b

View file

@ -44,6 +44,19 @@ Singleton {
property var agents: ({}) property var agents: ({})
property var agentList: Object.keys(agents) property var agentList: Object.keys(agents)
property string currentAgentId: "" property string currentAgentId: ""
// Auto-resume: whenever the active agent is (re)established shell start,
// reboot, agent switch re-attach her latest server-persisted conversation
// so the next message continues the thread instead of minting a fresh one.
// Without this every shell reload starts amnesiac, and the shell reloads
// often (a deploy, a lock, a crash). The server is the source of truth here;
// the surface deliberately keeps no agent conversation map of its own.
// Gated so it never clobbers a live turn or an already-attached thread.
onCurrentAgentIdChanged: {
if (root.currentAgentId.length > 0 && root.serverUp
&& root.conversationId.length === 0 && !root.turnActive) {
root.resumeLatestConversation();
}
}
property string conversationId: "" property string conversationId: ""
property bool turnActive: false property bool turnActive: false
@ -163,11 +176,30 @@ Singleton {
function selectAgent(agentId) { function selectAgent(agentId) {
if (!root.agents[agentId]) return false; if (!root.agents[agentId]) return false;
// Re-affirming the agent that is already active is a no-op, and it has
// to be. Ai.qml re-selects the persisted agent on every agentsRefreshed,
// and the inventory poll above fires that once a minute, forever. While
// this function cleared conversationId unconditionally, every message
// sent more than a minute after the previous one opened a NEW
// conversation and therefore arrived with no history at all.
//
// Measured on the phone 2026-07-31: 27 conversations for one agent in a
// day, all but two exactly four messages long system prompt, ambient,
// user, reply. One exchange each. That is the whole of the "she doesn't
// remember what I just said" report, and it is not the turn loop: the
// server assembles history from session.messages correctly, and there
// was simply never more than one exchange in a session to assemble.
//
// Switching agents is a decision. Polling is not.
if (agentId === root.currentAgentId) return true;
// A live turn belongs to the current conversation. Switching beneath // A live turn belongs to the current conversation. Switching beneath
// it would render one agent's response in another agent's surface. // it would render one agent's response in another agent's surface.
if (root.turnActive) return false; if (root.turnActive) return false;
root.currentAgentId = agentId; // Clear before the id changes, so onCurrentAgentIdChanged observes an
// empty conversation and re-attaches the incoming agent's own latest
// thread rather than leaving her on a blank one.
root.conversationId = ""; root.conversationId = "";
root.currentAgentId = agentId;
return true; return true;
} }
@ -184,25 +216,40 @@ Singleton {
property string agentId: "" property string agentId: ""
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
if (listConversations.agentId !== root.currentAgentId) return;
// Empty stdout is a FAILED request, not an empty agent: curl -sf
// writes nothing on 4xx/5xx. Conflating the two clears
// conversationId and hands the surface an empty transcript to
// render, so one transient hiccup wipes the visible thread and
// orphans the live one. Only a parsed response is authoritative.
if (text.length === 0) {
console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
return;
}
let conversations = []; let conversations = [];
try { try {
conversations = text.length > 0 ? JSON.parse(text) : []; conversations = JSON.parse(text);
} catch (e) { } catch (e) {
console.log("[Souveraine] Could not parse conversation list:", e); console.log("[Souveraine] Could not parse conversation list:", e);
return;
} }
if (listConversations.agentId !== root.currentAgentId) return;
if (conversations.length === 0) { if (conversations.length === 0) {
// The server answered, and the answer is "none yet".
root.conversationId = ""; root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []); root.conversationResumed(root.currentAgentId, "", []);
return; return;
} }
// Server sorts by updated_at descending [0] is her latest.
root._loadConversation(listConversations.agentId, conversations[0].id); root._loadConversation(listConversations.agentId, conversations[0].id);
} }
} }
onExited: exitCode => { onExited: exitCode => {
// A failed list fetch is transient (server busy, network blip).
// Do NOT clear conversationId that orphans the live thread and
// forces the next send to mint a fresh conversation. Log and leave
// state alone; the next refresh or /resume retries.
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) { if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
root.conversationId = ""; console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place");
root.conversationResumed(root.currentAgentId, "", []);
} }
} }
} }
@ -213,21 +260,29 @@ Singleton {
property string requestedConversationId: "" property string requestedConversationId: ""
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
try {
const messages = text.length > 0 ? JSON.parse(text) : [];
if (loadConversation.agentId !== root.currentAgentId) return; if (loadConversation.agentId !== root.currentAgentId) return;
// Same rule as the list above: no body means the fetch failed.
// Attaching to the id anyway and announcing an empty transcript
// would blank the surface while claiming the thread is loaded.
if (text.length === 0) {
console.log("[Souveraine] empty transcript response — leaving current conversation in place");
return;
}
try {
const messages = JSON.parse(text);
root.conversationId = loadConversation.requestedConversationId; root.conversationId = loadConversation.requestedConversationId;
root.conversationResumed(root.currentAgentId, root.conversationId, messages); root.conversationResumed(root.currentAgentId, root.conversationId, messages);
} catch (e) { } catch (e) {
console.log("[Souveraine] Could not parse conversation transcript:", e); console.log("[Souveraine] Could not parse conversation transcript:", e);
root.conversationResumed(root.currentAgentId, "", []);
} }
} }
} }
onExited: exitCode => { onExited: exitCode => {
// Transient failure (e.g. a 5xx on GET /messages). Don't clear
// see listConversations.onExited. Leaving conversationId alone keeps
// an already-attached thread reachable instead of forcing a new one.
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) { if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
root.conversationId = ""; console.log("[Souveraine] conversation transcript fetch failed (exit " + exitCode + ") — leaving current conversation in place");
root.conversationResumed(root.currentAgentId, "", []);
} }
} }
} }