PR #3588
Sections
Review

feat: add prompt history plugin Host prerequisites

main ← feature/design-prompt-histor-c82 163 files +4820 −610 PR #3588 ↗

This PR adds the browser Host boundary for prompt history: capability-gated conversation reads, ordered live reconciliation, and task-panel navigation so a future external plugin can replace the core panel.

Why this change

Prompt history reads private Zustand stores, first-party REST, and raw WebSocket events. A plugin cannot reuse that without copying internal state and transport logic.

What it does

Architecture, end to end

A plugin panel mounts through the Host scope. The scope binds to the backend, fetches a cutoff snapshot, and merges ordered live events. The backend journal and stream own persistence and sequencing.

flowchart LR
  Plugin["Plugin panel\nPromptHistoryPluginPanel"] --> Scope["OrderedConversationScope\nconversation-scope.tsx"]
  Scope --> Hooks["host.conversation\nuseSessionMessages / useSessionTurns"]
  Scope --> Binding["GET /conversation/binding\nX-Kandev-Plugin-Binding"]
  Binding --> Routes["conversation_handlers.go\n/messages /turns /renew"]
  Routes --> Journal["conversation_journal.go\nmessage_versions @ cutoff"]
  Routes --> Stream["conversation_stream.go\nSessionEventLog"]
  Stream --> WS["ordered_session_events.go\nHub fanout + ACK"]
  WS --> Scope
  Hooks --> UI["host.ui.PromptMentionText\nuseMessageFavorite"]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Frontend
Backend
SDK contract for browser conversationapps/packages/plugin-sdk/src/index.ts ↗
interface PluginConversationApi
Click for details →

The SDK defines the public, runtime-free types that plugins import and the Host implements.

Public types
export interface PluginConversationMessage {
  id: string;
  taskId: string | null;
  sessionId: string;
  turnId?: string;
  authorType: PluginConversationAuthor;
  type: string;
  content: string;
  createdAt: string;
  updatedAt: string;
  promptIndex?: number;
  senderTaskId?: string;
}

export interface PluginConversationTurn {
  id: string;
  taskId: string | null;
  sessionId: string;
  startedAt: string;
  completedAt?: string;
  updatedAt: string;
}

export interface PluginConversationApi {
  useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState;
  useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState;
  useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}

export interface PluginTaskPanelConversationCapability {
  openMessage(messageId: string): PluginOpenMessageResult;
  history: PluginConversationApi;
}
func (c *Controller) conversationMessages(ctx *gin.Context)
Click for details →

The backend exposes authenticated, plugin-scoped reads that enforce api_read:messages and session authorization.

Route registration
func registerConversationRoutes(api *gin.RouterGroup, ctrl *Controller) {
  api.GET("/:id/conversation/binding", ctrl.conversationBinding)
  api.GET("/:id/conversation/task-sessions/:sessionId/messages", ctrl.conversationMessages)
  api.GET("/:id/conversation/task-sessions/:sessionId/turns", ctrl.conversationTurns)
  api.POST("/:id/conversation/continuation/renew", ctrl.conversationContinuationRenew)
}
Message DTO mapping
func conversationMessageModelToDTO(message *taskmodels.Message) conversationMessageDTO {
  updatedAt := message.UpdatedAt
  if updatedAt.IsZero() {
    updatedAt = message.CreatedAt
  }
  dto := conversationMessageDTO{
    ID: message.ID,
    TaskID: nonEmptyStringPointer(message.TaskID),
    SessionID: message.TaskSessionID,
    AuthorType: string(message.AuthorType),
    Content: sysprompt.StripSystemContent(message.Content),
    CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339Nano),
    UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
  }
  if senderTaskID, ok := message.Metadata["sender_task_id"].(string); ok && senderTaskID != "" {
    dto.SenderTaskID = &senderTaskID
  }
  return dto
}
func (s *Service) conversationMessagesAt(...)
Click for details →

The journal serves deterministic pages from immutable version rows at the committed cutoff, not from live tables.

Snapshot query
func (s *Service) conversationMessagesAt(ctx context.Context, sessionID string, cutoff uint64, taskID *string, authors []string, sortOrder string, cursorID string, limit int) ([]*taskmodels.Message, bool, error) {
  where := []string{"version_rank = 1", "tombstone = FALSE"}
  if taskID != nil {
    where = append(where, "task_id = ?")
  }
  query := fmt.Sprintf(`
    WITH ranked AS (
      SELECT message_id, task_id, author_type, created_at, tombstone, payload,
        ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY row_sequence DESC) AS version_rank
      FROM conversation_message_versions
      WHERE session_id = ? AND row_sequence <= ?
    ), live AS (
      SELECT * FROM ranked WHERE version_rank = 1 AND tombstone = FALSE
    )
    SELECT payload FROM live
    WHERE %s
    ORDER BY created_at %s, message_id %s
    LIMIT ?`, strings.Join(where, " AND "), orderDirection, orderDirection)
  rows, err := s.conversationJournal.QueryxContext(ctx, query, args...)
  return decodeConversationSnapshotRows(rows, sessionID, limit)
}
func (l *SessionEventLog) Append(...) (SessionEvent, error)
Click for details →

The stream allocates a per-session sequence, persists the event, and tracks poison delivery with lease and retry.

Append with poison detection
func (l *SessionEventLog) Append(sessionID string, taskID *string, eventType string, payload json.RawMessage) (SessionEvent, error) {
  partition := l.state.Sessions[sessionID]
  if partition == nil {
    partition = &sessionEventPartition{}
    l.state.Sessions[sessionID] = partition
  }
  if partition.Terminal {
    return SessionEvent{}, ErrSessionRemoved
  }
  event := SessionEvent{
    ProtocolVersion: SessionEventProtocolVersion,
    EventType: eventType,
    SessionID: sessionID,
    TaskID: taskID,
    Sequence: partition.Watermark + 1,
    ID: uuid.NewString(),
    Payload: append(json.RawMessage(nil), payload...),
    CreatedAt: l.now().UTC(),
  }
  partition.Events = append(partition.Events, event)
  partition.Watermark = event.Sequence
  if _, err := ProjectSessionEvent(event); err != nil {
    l.state.Poison[poisonKey(sessionID, event.ID)] = &SessionPoisonRecord{
      SessionID: sessionID, EventID: event.ID, Sequence: event.Sequence,
      State: SessionPoisonPending, OwnerEpoch: 1,
    }
  }
  return event, l.persistAppendLocked(event, poison)
}
Hub fanout
func (h *Hub) appendAndBroadcastOrderedSessionEvent(sessionID string, message *ws.Message) {
  h.orderedSessionMu.Lock()
  defer h.orderedSessionMu.Unlock()
  service := h.pluginConversationService
  eventType := orderedEventTypeByAction[message.Action]
  if service.HasConversationJournal() {
    events, _ := service.SyncCommittedSessionEvents(context.Background(), sessionID)
    for _, event := range events {
      h.broadcastCommittedOrderedSessionEvent(service, event)
    }
    return
  }
  payload := sanitizedOrderedSessionPayload(eventType, source)
  event, _ := service.SessionEvents().Append(sessionID, taskID, eventType, encoded)
  h.deliverOrderedSessionEvent(service, event, h.orderedSessionRecipients(sessionID))
}
class OrderedConversationScope implements ConversationScope
Click for details →

The scope owns binding, watermark, buffering, and reconnect so hooks never handle transport directly.

Scope state
class OrderedConversationScope implements ConversationScope {
  private bindingPromise: Promise<Binding> | null = null;
  private readyPromise: Promise<OrderedReady> | null = null;
  private acknowledgedSequence = 0;
  private nextSequence = 1;
  private sequenceBlocked = false;
  private terminal = false;
  private readonly buffered: RawSessionEvent[] = [];
  private readonly pendingBySequence = new Map<number, RawSessionEvent>();
  private consumerId = generateUUID();

  async ready(): Promise<OrderedReady> {
    const pending = (this.readyPromise ??= this.initializeReady());
    return pending.then((current) => this.refreshBindingIfNeeded(current));
  }
}
Event acceptance
accept(event: RawSessionEvent) {
  if (!isRawSessionEvent(event)) {
    this.blockForPoison();
    return;
  }
  if (event.sequence < this.nextSequence) return;
  this.pendingBySequence.set(event.sequence, event);
  this.drainPending(sessionId);
}

private drainPending(sessionId: string) {
  while (!this.sequenceBlocked && !this.terminal) {
    const event = this.pendingBySequence.get(this.nextSequence);
    if (!event) return;
    if (!isCompatibleConversationEvent(event, sessionId)) {
      this.blockForPoison();
      return;
    }
    this.pendingBySequence.delete(this.nextSequence);
    this.nextSequence += 1;
    if (this.shouldBuffer(event)) {
      this.buffered.push(event);
      continue;
    }
    if (!this.project(event)) {
      this.sequenceBlocked = true;
      return;
    }
  }
}
function useSessionMessages(query: PluginSessionMessagesQuery)
Click for details →

The hooks merge paginated snapshots with live events, handle loadMore joining, and fence stale generations.

Message hook
function useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState {
  const scope = React.useContext(ConversationScopeContext);
  const [state, setState] = React.useState({ ...EMPTY_MESSAGES });
  const cursorRef = React.useRef<string | null>(null);
  const resolved = React.useMemo(() => scope ? resolveTaskId(scope, query.taskId) : { taskId: null, error: null }, [query.taskId, scope]);
  const snapshotKey = JSON.stringify([query.sessionId, resolved.taskId, authorsKey, sort, limit]);

  const loadPage = useMessagePageLoader({ scope, sessionId: query.sessionId, taskId: resolved.taskId, snapshotKey, setState, cursorRef });
  useOrderedMessageEvents({ scope, sessionId: query.sessionId, taskId: resolved.taskId, snapshotKey, setState });
  useInitialMessagePage({ scope, sessionId: query.sessionId, revision, loadPage, setState });
  return useMessageControls({ scope, sessionId: query.sessionId, state, setState, loadPage, cursorRef });
}
Page merge
function mergeMessagePage(current: readonly PluginConversationMessage[], page: readonly PluginConversationMessage[], append: boolean, sort: "asc" | "desc") {
  const existingIds = new Set(current.map((m) => m.id));
  const messagesById = new Map<string, PluginConversationMessage>();
  if (append) for (const m of current) messagesById.set(m.id, m);
  for (const m of page) {
    const prev = messagesById.get(m.id);
    if (!prev || prev.updatedAt.localeCompare(m.updatedAt) < 0) messagesById.set(m.id, m);
  }
  const messages = Array.from(messagesById.values());
  messages.sort((a, b) => compareConversationMessages(a, b, sort));
  return { messages, additionCount: page.filter((m) => !existingIds.has(m.id)).length };
}
Read the changes as a list

SDK contract for browser conversation

apps/packages/plugin-sdk/src/index.ts

The SDK defines the public, runtime-free types that plugins import and the Host implements.

Public types
export interface PluginConversationMessage {
  id: string;
  taskId: string | null;
  sessionId: string;
  turnId?: string;
  authorType: PluginConversationAuthor;
  type: string;
  content: string;
  createdAt: string;
  updatedAt: string;
  promptIndex?: number;
  senderTaskId?: string;
}

export interface PluginConversationTurn {
  id: string;
  taskId: string | null;
  sessionId: string;
  startedAt: string;
  completedAt?: string;
  updatedAt: string;
}

export interface PluginConversationApi {
  useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState;
  useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState;
  useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}

export interface PluginTaskPanelConversationCapability {
  openMessage(messageId: string): PluginOpenMessageResult;
  history: PluginConversationApi;
}

Capability-gated conversation routes

apps/backend/internal/plugins/conversation_handlers.go

The backend exposes authenticated, plugin-scoped reads that enforce api_read:messages and session authorization.

Route registration
func registerConversationRoutes(api *gin.RouterGroup, ctrl *Controller) {
  api.GET("/:id/conversation/binding", ctrl.conversationBinding)
  api.GET("/:id/conversation/task-sessions/:sessionId/messages", ctrl.conversationMessages)
  api.GET("/:id/conversation/task-sessions/:sessionId/turns", ctrl.conversationTurns)
  api.POST("/:id/conversation/continuation/renew", ctrl.conversationContinuationRenew)
}
Message DTO mapping
func conversationMessageModelToDTO(message *taskmodels.Message) conversationMessageDTO {
  updatedAt := message.UpdatedAt
  if updatedAt.IsZero() {
    updatedAt = message.CreatedAt
  }
  dto := conversationMessageDTO{
    ID: message.ID,
    TaskID: nonEmptyStringPointer(message.TaskID),
    SessionID: message.TaskSessionID,
    AuthorType: string(message.AuthorType),
    Content: sysprompt.StripSystemContent(message.Content),
    CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339Nano),
    UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
  }
  if senderTaskID, ok := message.Metadata["sender_task_id"].(string); ok && senderTaskID != "" {
    dto.SenderTaskID = &senderTaskID
  }
  return dto
}

Journal snapshot at cutoff

apps/backend/internal/plugins/conversation_journal.go

The journal serves deterministic pages from immutable version rows at the committed cutoff, not from live tables.

Snapshot query
func (s *Service) conversationMessagesAt(ctx context.Context, sessionID string, cutoff uint64, taskID *string, authors []string, sortOrder string, cursorID string, limit int) ([]*taskmodels.Message, bool, error) {
  where := []string{"version_rank = 1", "tombstone = FALSE"}
  if taskID != nil {
    where = append(where, "task_id = ?")
  }
  query := fmt.Sprintf(`
    WITH ranked AS (
      SELECT message_id, task_id, author_type, created_at, tombstone, payload,
        ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY row_sequence DESC) AS version_rank
      FROM conversation_message_versions
      WHERE session_id = ? AND row_sequence <= ?
    ), live AS (
      SELECT * FROM ranked WHERE version_rank = 1 AND tombstone = FALSE
    )
    SELECT payload FROM live
    WHERE %s
    ORDER BY created_at %s, message_id %s
    LIMIT ?`, strings.Join(where, " AND "), orderDirection, orderDirection)
  rows, err := s.conversationJournal.QueryxContext(ctx, query, args...)
  return decodeConversationSnapshotRows(rows, sessionID, limit)
}

Durable ordered event stream

apps/backend/internal/plugins/conversation_stream.go

The stream allocates a per-session sequence, persists the event, and tracks poison delivery with lease and retry.

Append with poison detection
func (l *SessionEventLog) Append(sessionID string, taskID *string, eventType string, payload json.RawMessage) (SessionEvent, error) {
  partition := l.state.Sessions[sessionID]
  if partition == nil {
    partition = &sessionEventPartition{}
    l.state.Sessions[sessionID] = partition
  }
  if partition.Terminal {
    return SessionEvent{}, ErrSessionRemoved
  }
  event := SessionEvent{
    ProtocolVersion: SessionEventProtocolVersion,
    EventType: eventType,
    SessionID: sessionID,
    TaskID: taskID,
    Sequence: partition.Watermark + 1,
    ID: uuid.NewString(),
    Payload: append(json.RawMessage(nil), payload...),
    CreatedAt: l.now().UTC(),
  }
  partition.Events = append(partition.Events, event)
  partition.Watermark = event.Sequence
  if _, err := ProjectSessionEvent(event); err != nil {
    l.state.Poison[poisonKey(sessionID, event.ID)] = &SessionPoisonRecord{
      SessionID: sessionID, EventID: event.ID, Sequence: event.Sequence,
      State: SessionPoisonPending, OwnerEpoch: 1,
    }
  }
  return event, l.persistAppendLocked(event, poison)
}
Hub fanout
func (h *Hub) appendAndBroadcastOrderedSessionEvent(sessionID string, message *ws.Message) {
  h.orderedSessionMu.Lock()
  defer h.orderedSessionMu.Unlock()
  service := h.pluginConversationService
  eventType := orderedEventTypeByAction[message.Action]
  if service.HasConversationJournal() {
    events, _ := service.SyncCommittedSessionEvents(context.Background(), sessionID)
    for _, event := range events {
      h.broadcastCommittedOrderedSessionEvent(service, event)
    }
    return
  }
  payload := sanitizedOrderedSessionPayload(eventType, source)
  event, _ := service.SessionEvents().Append(sessionID, taskID, eventType, encoded)
  h.deliverOrderedSessionEvent(service, event, h.orderedSessionRecipients(sessionID))
}

Ordered conversation scope

apps/web/lib/plugins/conversation-scope.tsx

The scope owns binding, watermark, buffering, and reconnect so hooks never handle transport directly.

Scope state
class OrderedConversationScope implements ConversationScope {
  private bindingPromise: Promise<Binding> | null = null;
  private readyPromise: Promise<OrderedReady> | null = null;
  private acknowledgedSequence = 0;
  private nextSequence = 1;
  private sequenceBlocked = false;
  private terminal = false;
  private readonly buffered: RawSessionEvent[] = [];
  private readonly pendingBySequence = new Map<number, RawSessionEvent>();
  private consumerId = generateUUID();

  async ready(): Promise<OrderedReady> {
    const pending = (this.readyPromise ??= this.initializeReady());
    return pending.then((current) => this.refreshBindingIfNeeded(current));
  }
}
Event acceptance
accept(event: RawSessionEvent) {
  if (!isRawSessionEvent(event)) {
    this.blockForPoison();
    return;
  }
  if (event.sequence < this.nextSequence) return;
  this.pendingBySequence.set(event.sequence, event);
  this.drainPending(sessionId);
}

private drainPending(sessionId: string) {
  while (!this.sequenceBlocked && !this.terminal) {
    const event = this.pendingBySequence.get(this.nextSequence);
    if (!event) return;
    if (!isCompatibleConversationEvent(event, sessionId)) {
      this.blockForPoison();
      return;
    }
    this.pendingBySequence.delete(this.nextSequence);
    this.nextSequence += 1;
    if (this.shouldBuffer(event)) {
      this.buffered.push(event);
      continue;
    }
    if (!this.project(event)) {
      this.sequenceBlocked = true;
      return;
    }
  }
}

Host conversation hooks

apps/web/lib/plugins/conversation-host.tsx

The hooks merge paginated snapshots with live events, handle loadMore joining, and fence stale generations.

Message hook
function useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState {
  const scope = React.useContext(ConversationScopeContext);
  const [state, setState] = React.useState({ ...EMPTY_MESSAGES });
  const cursorRef = React.useRef<string | null>(null);
  const resolved = React.useMemo(() => scope ? resolveTaskId(scope, query.taskId) : { taskId: null, error: null }, [query.taskId, scope]);
  const snapshotKey = JSON.stringify([query.sessionId, resolved.taskId, authorsKey, sort, limit]);

  const loadPage = useMessagePageLoader({ scope, sessionId: query.sessionId, taskId: resolved.taskId, snapshotKey, setState, cursorRef });
  useOrderedMessageEvents({ scope, sessionId: query.sessionId, taskId: resolved.taskId, snapshotKey, setState });
  useInitialMessagePage({ scope, sessionId: query.sessionId, revision, loadPage, setState });
  return useMessageControls({ scope, sessionId: query.sessionId, state, setState, loadPage, cursorRef });
}
Page merge
function mergeMessagePage(current: readonly PluginConversationMessage[], page: readonly PluginConversationMessage[], append: boolean, sort: "asc" | "desc") {
  const existingIds = new Set(current.map((m) => m.id));
  const messagesById = new Map<string, PluginConversationMessage>();
  if (append) for (const m of current) messagesById.set(m.id, m);
  for (const m of page) {
    const prev = messagesById.get(m.id);
    if (!prev || prev.updatedAt.localeCompare(m.updatedAt) < 0) messagesById.set(m.id, m);
  }
  const messages = Array.from(messagesById.values());
  messages.sort((a, b) => compareConversationMessages(a, b, sort));
  return { messages, additionCount: page.filter((m) => !existingIds.has(m.id)).length };
}

Data and storage

The journal stores immutable version rows at a cutoff. The stream stores ordered events with poison state. The frontend scope holds the binding and cursor that tie them together.

flowchart TD
  MsgVer["conversation_message_versions\nmessage_id, row_sequence, payload, tombstone"] --> Snapshot["Snapshot @ cutoff\nROW_NUMBER() OVER (PARTITION BY message_id)"]
  TurnVer["conversation_turn_versions\nturn_id, row_sequence, payload"] --> Snapshot
  Snapshot --> DTO["PluginConversationMessage\nPluginConversationTurn"]
  Stream["SessionEventLog\nSessions / Cursors / Poison"] --> Event["SessionEvent\nsequence, event_id, payload"]
  Event --> Scope["OrderedConversationScope\nconsumer_id, watermark, cursor"]
  Scope --> Hook["useSessionMessages\nuseSessionTurns"]
FieldTypeNotes
conversation_message_versions.row_sequenceintegermonotonic per-message version, cutoff is max row_sequence
conversation_message_versions.tombstonebooleantrue hides the message at and after cutoff
SessionEvent.sequenceuint64per-session monotonic, allocated in one transaction
SessionDeliveryCursor.acknowledged_sequenceuint64highest contiguous ACK, per consumer
SessionPoisonRecord.stateenumpending, leased, exhausted, requeued with 30s lease
PluginConversationMessage.promptIndexinteger | nulldurable absolute index, never renumbers
conversation_tokens.bindingTokenJWTplugin, user, generation, expires <=10m

Risk

6 / 10 Medium
1 low5 medium10 high

Why this score

  • 163 files across auth, persistence, WebSocket, and UI; a regression can break session reads or live updates.
  • New durable tables and token flows add migration and retention cost, but are additive and gated by capability.
  • Extensive backend and frontend tests plus fixture E2E cover pagination, poison, reconnect, and removal.

Trade-offs and review notes

Where to look first

  1. Verify conversation_handlers.go enforces api_read:messages, generation, and taskId matching before any read.
  2. Check conversation-scope.tsx buffers live events until snapshot commit and fences stale generations.
  3. Confirm conversation_journal.go selects max row_sequence <= cutoff and respects tombstones.
  4. Review conversation_stream.go for poison detection, lease, and terminal session.removed handling.