fix: prevent duplicate assistant messages in DB

Root cause: the chat handler saved assistant messages from JSONL after
the stream ended, while the poll endpoint also synced JSONL to DB
periodically. Gateway v4 rewrites JSONL entries during multi-tool-call
turns, changing the externalId for the same logical message between
reads. Each poll saw a 'new' externalId and inserted another copy.

Fix: remove assistant message persistence from the chat handler.
The poll endpoint already handles JSONL-to-DB sync with robust dedup.
The chat handler now only links the user message externalId.

Also: API_HOST env var support for binding to Tailscale-only.
This commit is contained in:
Bobby Cude
2026-05-15 21:19:23 -05:00
parent bf6f12bbcc
commit dcc4f94031
2 changed files with 8 additions and 30 deletions
+2 -1
View File
@@ -48,7 +48,8 @@ const PORT = Number(process.env.PORT) || 18802;
: colors.yellow('[gateway] initial connection failed, will use CLI fallback')
);
const server = app.listen(PORT, () => console.log(colors.green(`running on port ${PORT}`)));
const API_HOST = process.env.API_HOST || '0.0.0.0';
const server = app.listen(PORT, API_HOST, () => console.log(colors.green(`running on ${API_HOST}:${PORT}`)));
attachPtyWebSocket(server);
startUpdateChecker();
} catch (error) {
+6 -29
View File
@@ -200,42 +200,19 @@ const chat: Chat = async (req, res, next) => {
.catch(() => {});
}
// Fetch messages from JSONL to get externalIds
// Link the user message to its JSONL externalId so the poll
// endpoint can recognise it later and skip duplicate inserts.
// Assistant message persistence is left entirely to the poll
// endpoint which has robust dedup logic; saving it here as well
// caused duplicate rows because the JSONL externalId can shift
// between reads (gateway v4 multi-pass writes).
try {
const jsonlMessages = ocService.getSessionMessages(agentIdForFiles, sessionKey);
if (jsonlMessages.length) {
const lastUserJsonl = [...jsonlMessages].reverse().find((m) => m.role === 'user');
const lastAssistantJsonl = [...jsonlMessages].reverse().find((m) => m.role === 'assistant');
if (lastUserJsonl?.externalId) {
await msgRepo.update(savedUser._id, { externalId: lastUserJsonl.externalId });
}
// Save assistant message from JSONL
if (lastAssistantJsonl) {
const assistantText = stripWrapperTags(lastAssistantJsonl.text).trim();
const assistantThinking = lastAssistantJsonl.thinking
? stripWrapperTags(lastAssistantJsonl.thinking).trim()
: null;
const assistantToolSteps = lastAssistantJsonl.toolSteps ?? null;
/* Persist if we got any signal: real text, thinking, or tool calls
* — the last produces a compact tool-stub bubble in the UI. */
if (assistantText || assistantThinking || (assistantToolSteps && assistantToolSteps.length > 0)) {
const assistantMessage = msgRepo.create({
conversationId: Number(conversationId),
externalId: lastAssistantJsonl.externalId || null,
text: assistantText,
thinking: assistantThinking || null,
toolSteps:
assistantToolSteps && assistantToolSteps.length > 0 ? assistantToolSteps : null,
role: 'assistant' as const,
createdBy: req.user!._id,
createdAt: new Date(),
});
await msgRepo.save(assistantMessage);
}
}
}
} catch {
// Non-critical