Skip to content

feat(api): add agent message persistence and simplified event stream#202

Merged
jlia0 merged 10 commits intomainfrom
claude/simplify-agent-events-QbDpg
Mar 12, 2026
Merged

feat(api): add agent message persistence and simplified event stream#202
jlia0 merged 10 commits intomainfrom
claude/simplify-agent-events-QbDpg

Conversation

@jlia0
Copy link
Copy Markdown
Collaborator

@jlia0 jlia0 commented Mar 12, 2026

Description

Add a new agent_messages table and REST API endpoints to persist and retrieve agent chat history. This enables simplified single-agent chat workflows by providing a dedicated agent_message SSE event that fires once per agent response, eliminating the need to listen to the full chain_step_start → chain_step_done → response_ready lifecycle.

Changes

  • Database: Add agent_messages table with indexed queries for agent-specific and channel-filtered message retrieval
  • Core API: Add insertAgentMessage() and three query functions (getAgentMessages, getAgentMessagesByChannel, getAllAgentMessages)
  • Main processor: Emit new agent_message SSE event after each agent response; persist both user inputs and assistant responses to the agent_messages table
  • Server routes: Add /api/agent-messages and /api/agents/:id/messages endpoints with limit and since_id pagination
  • Documentation: Update SSE-EVENTS.md with agent_message event schema and simplified event flow diagrams

Testing

  • Verified database schema creation and index definitions
  • Tested message insertion and retrieval with various query filters
  • Confirmed SSE event emission in message processing pipeline
  • API endpoints follow existing pagination patterns used elsewhere in the codebase

Checklist

  • PR title follows conventional commit format
  • Changes tested locally
  • Documentation updated with new event and API endpoints
  • No new warnings or errors introduced

https://claude.ai/code/session_01GJsNQwdZvdVPrAkWswf5BJ

… single-agent chat

For tinyoffice, instead of listening to the full chain_step_start → chain_step_done →
response_ready lifecycle, clients can now subscribe to a single `agent_message` SSE event
that fires once per agent response with full content.

- Add `agent_messages` SQLite table to persist all agent chat history (user + assistant)
- Emit `agent_message` event after each agent response (includes isTeamMessage flag)
- Add REST API: GET /api/agent-messages, GET /api/agents/:id/messages
- Update SSE-EVENTS.md with new event and API docs

https://claude.ai/code/session_01GJsNQwdZvdVPrAkWswf5BJ
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR adds an agent_messages SQLite table with REST endpoints (GET /api/agent-messages, GET /api/agents/:id/messages) and a new agent_message SSE event to simplify single-agent chat history workflows. The overall design is clean and consistent with existing patterns in the codebase.

Key issues found:

  • Pagination bug in channel-filtered endpoint (packages/server/src/routes/agent-messages.ts line 20–22, packages/core/src/queues.ts getAgentMessagesByChannel): When a channel query param is present, the since_id cursor is silently ignored because getAgentMessagesByChannel has no sinceId parameter. The documentation explicitly advertises ?since_id=0&channel=whatsapp, so clients doing incremental polling on a specific channel will always receive the full history instead of only new messages.

  • No pruning for agent_messages table (packages/core/src/queues.ts): Every other table (messages, responses) has a corresponding prune function called in the 60-second maintenance loop. agent_messages has none, so the table will grow indefinitely in long-running deployments.

  • agent_message event and persistence fire for error-fallback responses (packages/main/src/index.ts lines 113–123): When invokeAgent throws, a static error string is used as the response, then the new block unconditionally persists it to agent_messages and emits an agent_message SSE event. Clients treating agent_message as a reliable signal of a real agent response will silently ingest fabricated messages.

Confidence Score: 3/5

  • Safe to merge after fixing the since_id pagination bug in the channel-filtered endpoint; the other two issues are lower risk but worth addressing.
  • The core feature is implemented correctly and the schema/indexes are sound. However, the silent since_id drop when channel is provided is a correctness bug that contradicts the documented API contract and will cause subtle data integrity issues for polling clients. The missing pruning and error-response persistence are operational/quality concerns rather than blockers, but taken together they lower confidence.
  • packages/server/src/routes/agent-messages.ts and packages/core/src/queues.ts — the channel-filter pagination bug spans both files.

Important Files Changed

Filename Overview
packages/server/src/routes/agent-messages.ts New REST API routes for agent message history; since_id pagination cursor is silently dropped when the channel filter is used, breaking documented pagination behavior.
packages/core/src/queues.ts Adds agent_messages table and four CRUD functions; getAgentMessagesByChannel lacks sinceId support and no pruning function is provided, unlike every other table in this file.
packages/main/src/index.ts Wires message persistence and agent_message SSE event into the processing pipeline; the new block fires unconditionally, including for error-fallback responses.
packages/server/src/index.ts Trivial route registration for the new agent-messages router; no issues.
docs/SSE-EVENTS.md Documentation update for the new agent_message event and REST endpoints; accurate and well-structured.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Server as API Server (Hono)
    participant Main as Queue Processor
    participant DB as SQLite (agent_messages)
    participant SSE as SSE Stream

    Client->>Server: POST /api/messages (enqueue)
    Server->>Main: queueEvents "message:enqueued"
    Main->>Main: processMessage()
    Main->>Main: invokeAgent()
    Main->>DB: insertAgentMessage(role=user)
    Main->>DB: insertAgentMessage(role=assistant)
    Main->>SSE: emitEvent("agent_message", {content, agentId, ...})
    SSE-->>Client: SSE: agent_message event

    Client->>Server: GET /api/agents/:id/messages?since_id=42
    Server->>DB: getAgentMessages(agentId, limit, sinceId)
    DB-->>Server: rows[]
    Server-->>Client: JSON response

    Client->>Server: GET /api/agents/:id/messages?channel=whatsapp&since_id=42
    Server->>DB: getAgentMessagesByChannel(agentId, channel, limit)
    Note over Server,DB: ⚠️ since_id is silently dropped here
    DB-->>Server: rows[] (from row 0, not since_id)
    Server-->>Client: JSON response (pagination broken)
Loading

Last reviewed commit: 652d606

Comment on lines +20 to +22
if (channel) {
return c.json(getAgentMessagesByChannel(agentId, channel, limit));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since_id silently dropped when channel filter is used

When a channel query param is provided, sinceId is parsed from the request but never passed to getAgentMessagesByChannel, which has no sinceId parameter. This means pagination cursors are silently discarded and clients will always receive messages starting from the beginning of the history, breaking the documented ?limit=100&since_id=0&channel=whatsapp interface.

getAgentMessagesByChannel in packages/core/src/queues.ts needs a sinceId parameter, and the query should include AND id > ? to honour the cursor:

Suggested change
if (channel) {
return c.json(getAgentMessagesByChannel(agentId, channel, limit));
}
if (channel) {
return c.json(getAgentMessagesByChannel(agentId, channel, limit, sinceId));
}

And in queues.ts:

export function getAgentMessagesByChannel(agentId: string, channel: string, limit = 100, sinceId = 0): any[] {
    return getDb().prepare(
        `SELECT * FROM agent_messages WHERE agent_id=? AND channel=? AND id>? ORDER BY created_at DESC LIMIT ?`
    ).all(agentId, channel, sinceId, limit);
}

Comment on lines +192 to +220
// ── Agent messages (per-agent chat history) ─────────────────────────────────

export function insertAgentMessage(data: {
agentId: string; role: 'user' | 'assistant';
channel: string; sender: string; messageId: string; content: string;
}): number {
return getDb().prepare(
`INSERT INTO agent_messages (agent_id,role,channel,sender,message_id,content,created_at) VALUES (?,?,?,?,?,?,?)`
).run(data.agentId, data.role, data.channel, data.sender, data.messageId, data.content, Date.now()).lastInsertRowid as number;
}

export function getAgentMessages(agentId: string, limit = 100, sinceId = 0): any[] {
return getDb().prepare(
`SELECT * FROM agent_messages WHERE agent_id=? AND id>? ORDER BY created_at DESC LIMIT ?`
).all(agentId, sinceId, limit);
}

export function getAgentMessagesByChannel(agentId: string, channel: string, limit = 100): any[] {
return getDb().prepare(
`SELECT * FROM agent_messages WHERE agent_id=? AND channel=? ORDER BY created_at DESC LIMIT ?`
).all(agentId, channel, limit);
}

export function getAllAgentMessages(limit = 100, sinceId = 0): any[] {
return getDb().prepare(
`SELECT * FROM agent_messages WHERE id>? ORDER BY created_at DESC LIMIT ?`
).all(sinceId, limit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent_messages table has no pruning function

Every other table in this file has a corresponding pruning function (pruneCompletedMessages, pruneAckedResponses), and the maintenance loop in packages/main/src/index.ts calls both every 60 seconds. agent_messages has no equivalent, so the table will grow without bound in long-running deployments.

Consider adding a pruning function and wiring it into the maintenance interval:

export function pruneAgentMessages(olderThanMs = 7 * 86400000): number {
    return getDb().prepare(`DELETE FROM agent_messages WHERE created_at<?`).run(Date.now() - olderThanMs).changes;
}

Comment on lines +113 to +123
// ── Persist & emit simplified agent_message event ────────────────────
if (!isInternal) {
insertAgentMessage({ agentId, role: 'user', channel, sender, messageId, content: rawMessage });
}
insertAgentMessage({ agentId, role: 'assistant', channel, sender, messageId, content: response });
emitEvent('agent_message', {
agentId, agentName: agent.name, role: 'assistant',
channel, sender, messageId,
content: response,
isTeamMessage: isInternal || isTeamRouted,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent_message event / persistence fires for error-fallback responses

When invokeAgent throws, the catch block (lines 105–110) sets response to a static error string ("Sorry, I encountered an error..."). The new persistence and SSE emit code runs unconditionally after chain_step_done, so:

  1. The error string gets written to agent_messages as an 'assistant' message.
  2. An agent_message SSE event is emitted with the same error string as content.

Clients subscribing to agent_message as the "simplified" interface will receive (and store, if they're building their own UI) fabricated assistant messages. Consider skipping insertion and emission when the response originated from the error handler, e.g. by introducing a flag:

let responseIsError = false;
try {
    response = await invokeAgent(...);
} catch (error) {
    ...
    response = "Sorry, I encountered an error processing your request.";
    responseIsError = true;
}

if (!responseIsError) {
    if (!isInternal) {
        insertAgentMessage({ agentId, role: 'user', channel, sender, messageId, content: rawMessage });
    }
    insertAgentMessage({ agentId, role: 'assistant', channel, sender, messageId, content: response });
    emitEvent('agent_message', { ... });
}

@jlia0 jlia0 merged commit 4431775 into main Mar 12, 2026
@jlia0 jlia0 deleted the claude/simplify-agent-events-QbDpg branch March 12, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants