PR #3588
Sections
Review

feat: add prompt history plugin Host prerequisites

main ← feature/design-prompt-histor-c82 174 files +8420 −1180 PR #3588 ↗

Add Host-owned browser and backend contracts so a future external plugin can render prompt history through sanitized reads, ordered live events, and scoped navigation.

Why this change

Prompt history lives only as private core code. No public Host contract exists for a plugin to read prompts, turns, or navigate the transcript.

What it does

Architecture, end to end

Plugin panel calls Host hooks. Host fetches binding and snapshot, reads pages, and subscribes to ordered WS events. Backend serves sanitized snapshots and appends ordered events.

flowchart LR
  Panel[Plugin task panel] --> Host[Host facade\nconversation-host + scope]
  Host --> Binding[GET /conversation/binding]
  Host --> Messages[GET /messages + /turns]
  Host --> WS[WS session.subscribe + session.event]
  Messages --> Journal[(Conversation journal\nmessage/turn versions)]
  WS --> Log[(SessionEventLog\nordered stream)]
  Log --> Hub[WS Hub\nordered_session_events]
  Hub --> Host
  Panel --> Nav[conversation.openMessage]
  Nav --> Chat[Task chat scroll target]

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

func (c *Controller) conversationMessages(ctx *gin.Context)
Click for details →

The handler checks binding and snapshot tokens, validates query, and returns sanitized paginated messages or turns.

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 handler core
func (c *Controller) conversationMessages(ctx *gin.Context) {
  ctx.Header("Cache-Control", conversationNoStore)
  record, identity, ok := c.authorizeConversationRequest(ctx)
  if !ok || !c.validBinding(ctx, record, identity.UserID) {
    return
  }
  query, ok := parseConversationMessageQuery(ctx)
  if !ok {
    return
  }
  snapshot, ok := c.validSnapshot(ctx, record, identity.UserID, sessionID)
  if !ok {
    return
  }
  messages, hasMore, err = c.svc.conversationMessagesAt(ctx.Request.Context(), sessionID, uint64(snapshot.Cutoff), query.taskID, query.authors, query.sort, cursorID, query.limit)
  response, ok := c.buildConversationMessagesResponse(ctx, record, identity.UserID, sessionID, query, snapshot, messages, hasMore)
  ctx.JSON(http.StatusOK, response)
}
func (s *Service) conversationMessagesAt(ctx context.Context, sessionID string, cutoff uint64, ...)
Click for details →

The journal reads message and turn versions at a committed cutoff and strips system content and private metadata.

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 = ?")
  }
  orderDirection := "ASC"
  comparison := ">"
  if sortOrder == "desc" {
    orderDirection = "DESC"
    comparison = "<"
  }
  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)
  return decodeConversationSnapshotRows(rows, sessionID, limit)
}
Sanitization
func sanitizeConversationMessagePayload(target, source map[string]any) {
  if content, ok := source["content"].(string); ok {
    target["content"] = sysprompt.StripSystemContent(content)
  }
  sanitizedMetadata := SanitizeConversationMessageMetadata(metadata)
  if senderTaskID, ok := source["sender_task_id"].(string); ok && senderTaskID != "" {
    target["sender_task_id"] = senderTaskID
  }
}
func (l *SessionEventLog) Append(sessionID string, taskID *string, eventType string, payload json.RawMessage) (SessionEvent, error)
Click for details →

The log assigns a monotonic sequence, detects poison events, and persists the partition atomically.

Append with poison check
func (l *SessionEventLog) Append(sessionID string, taskID *string, eventType string, payload json.RawMessage) (SessionEvent, error) {
  if !json.Valid(payload) {
    return SessionEvent{}, fmt.Errorf("%w: malformed payload", ErrPoisonEvent)
  }
  l.mu.Lock()
  defer l.mu.Unlock()
  partition := l.state.Sessions[sessionID]
  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 _, projectionErr := ProjectSessionEvent(event); projectionErr != nil {
    poison = &SessionPoisonRecord{SessionID: sessionID, EventID: event.ID, Sequence: event.Sequence, State: SessionPoisonPending}
    l.state.Poison[poisonKey(sessionID, event.ID)] = poison
  }
  return event, l.persistAppendLocked(event, poison)
}
func (h *Hub) appendAndBroadcastOrderedSessionEvent(sessionID string, message *ws.Message)
Click for details →

The hub mirrors committed journal events or appends live events, then delivers through the poison-aware dispatcher.

Hub bridge
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, err := service.SyncCommittedSessionEvents(context.Background(), sessionID)
    for _, event := range events {
      h.broadcastCommittedOrderedSessionEvent(service, event)
    }
    return
  }
  payload := sanitizedOrderedSessionPayload(eventType, source)
  encoded, _ := json.Marshal(payload)
  event, err := 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, snapshot tokens, ordered WS subscription, gap and poison recovery, and snapshot buffering.

Scope interface
export type ConversationScope = {
  pluginId: string;
  taskId: string;
  sessionId: string | null;
  signal: AbortSignal;
  ready(): Promise<OrderedReady>;
  renewContinuation(cursor: string, queryIdentity?: string): Promise<{ cursor: string; binding: OrderedReady }>;
  subscribe(listener: ConversationEventListener, kind: SnapshotKind, snapshotKey: SnapshotKey): () => void;
  subscribeRebind(listener: () => void): () => void;
  commitSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void;
  invalidateSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void;
  accept(event: RawSessionEvent): void;
  close(): void;
};
Ordered accept and buffering
accept(event: RawSessionEvent) {
  if (!isRawSessionEvent(event)) { this.blockForPoison(); return; }
  if (!this.acceptsScope(event, sessionId) || event.sequence < this.nextSequence) return;
  this.pendingBySequence.set(event.sequence, event);
  this.drainPending(sessionId);
}
private shouldBuffer(event: RawSessionEvent) {
  if (this.listeners.size === 0) return true;
  const eventKind = snapshotKindForEvent(event.event_type);
  for (const listener of this.listeners) {
    if (this.listenerSnapshotKinds.get(listener) !== eventKind) continue;
    const key = this.listenerSnapshotKeys.get(listener) ?? "";
    if (!this.committedSnapshots.has(`${eventKind}:${key}`)) return true;
  }
  return false;
}
function useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState
Click for details →

The hooks fetch pages with binding and snapshot headers, merge pages, and project live ordered events.

Message page loader
async function fetchMessagePage({ scope, sessionId, taskId, authorsKey, sort, limit, cursor, binding }): Promise<MessagePage> {
  const params = new URLSearchParams({ sort, limit: String(limit) });
  if (taskId !== null) params.set("task_id", taskId);
  for (const author of authorsKey ? authorsKey.split(",") : []) params.append("author_type", author);
  if (cursor) params.set("cursor", cursor);
  const response = await fetch(pluginConversationUrl(scope.pluginId, `/conversation/task-sessions/${encodeURIComponent(sessionId)}/messages?${params}`), {
    credentials: "include",
    cache: "no-store",
    headers: { "X-Kandev-Plugin-Binding": binding.bindingToken, "X-Kandev-Snapshot-Token": binding.snapshotToken },
    signal: scope.signal,
  });
  return parseConversationResponse<MessagePage>(response);
}
Live projection
React.useEffect(() => {
  if (!scope || !sessionId) return;
  return scope.subscribe((event) => {
    if (event.event_type === "session.removed") {
      setState((current) => ({ ...current, removed: true, hasMore: false }));
      return true;
    }
    if (!event.event_type.startsWith("message.") || !eventMatchesTask(event, taskId)) return true;
    const message = messageFromEvent(event);
    if (!message) return false;
    setState((current) => {
      const messages = current.messages.filter((item) => item.id !== message.id);
      messages.push(message);
      messages.sort((left, right) => compareConversationMessages(left, right, sort));
      return { ...current, messages };
    });
    return true;
  }, "messages", snapshotKey);
}, [scope, sessionId, taskId, sort, snapshotKey]);
Public SDK and task-panel contractapps/packages/plugin-sdk/src/index.ts ↗
export interface PluginConversationApi
Click for details →

The SDK adds conversation types and extends task-panel registration with title key, visibility, and scoped navigation.

SDK 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 PluginConversationApi {
  useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState;
  useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState;
  useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}
export interface PluginTaskPanelProps extends PluginTaskPanelContext {
  panelId: string;
  conversation: PluginTaskPanelConversationCapability;
}
Host wiring
export function buildHostApi(pluginId: string, storeApi: StoreApi<AppState>): PluginHostApi {
  return {
    pluginId,
    React,
    jsx: React.createElement,
    i18n: buildPluginI18nApi(pluginId),
    conversation: pluginConversationApi,
    ui: createPluginUIApi(pluginId),
    // ...
  };
}
Read the changes as a list

Plugin-scoped conversation read routes

apps/backend/internal/plugins/conversation_handlers.go

The handler checks binding and snapshot tokens, validates query, and returns sanitized paginated messages or turns.

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 handler core
func (c *Controller) conversationMessages(ctx *gin.Context) {
  ctx.Header("Cache-Control", conversationNoStore)
  record, identity, ok := c.authorizeConversationRequest(ctx)
  if !ok || !c.validBinding(ctx, record, identity.UserID) {
    return
  }
  query, ok := parseConversationMessageQuery(ctx)
  if !ok {
    return
  }
  snapshot, ok := c.validSnapshot(ctx, record, identity.UserID, sessionID)
  if !ok {
    return
  }
  messages, hasMore, err = c.svc.conversationMessagesAt(ctx.Request.Context(), sessionID, uint64(snapshot.Cutoff), query.taskID, query.authors, query.sort, cursorID, query.limit)
  response, ok := c.buildConversationMessagesResponse(ctx, record, identity.UserID, sessionID, query, snapshot, messages, hasMore)
  ctx.JSON(http.StatusOK, response)
}

Snapshot journal with sanitized DTOs

apps/backend/internal/plugins/conversation_journal.go

The journal reads message and turn versions at a committed cutoff and strips system content and private metadata.

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 = ?")
  }
  orderDirection := "ASC"
  comparison := ">"
  if sortOrder == "desc" {
    orderDirection = "DESC"
    comparison = "<"
  }
  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)
  return decodeConversationSnapshotRows(rows, sessionID, limit)
}
Sanitization
func sanitizeConversationMessagePayload(target, source map[string]any) {
  if content, ok := source["content"].(string); ok {
    target["content"] = sysprompt.StripSystemContent(content)
  }
  sanitizedMetadata := SanitizeConversationMessageMetadata(metadata)
  if senderTaskID, ok := source["sender_task_id"].(string); ok && senderTaskID != "" {
    target["sender_task_id"] = senderTaskID
  }
}

Durable ordered session event log

apps/backend/internal/plugins/conversation_stream.go

The log assigns a monotonic sequence, detects poison events, and persists the partition atomically.

Append with poison check
func (l *SessionEventLog) Append(sessionID string, taskID *string, eventType string, payload json.RawMessage) (SessionEvent, error) {
  if !json.Valid(payload) {
    return SessionEvent{}, fmt.Errorf("%w: malformed payload", ErrPoisonEvent)
  }
  l.mu.Lock()
  defer l.mu.Unlock()
  partition := l.state.Sessions[sessionID]
  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 _, projectionErr := ProjectSessionEvent(event); projectionErr != nil {
    poison = &SessionPoisonRecord{SessionID: sessionID, EventID: event.ID, Sequence: event.Sequence, State: SessionPoisonPending}
    l.state.Poison[poisonKey(sessionID, event.ID)] = poison
  }
  return event, l.persistAppendLocked(event, poison)
}

WS ordered event fanout

apps/backend/internal/gateway/websocket/ordered_session_events.go

The hub mirrors committed journal events or appends live events, then delivers through the poison-aware dispatcher.

Hub bridge
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, err := service.SyncCommittedSessionEvents(context.Background(), sessionID)
    for _, event := range events {
      h.broadcastCommittedOrderedSessionEvent(service, event)
    }
    return
  }
  payload := sanitizedOrderedSessionPayload(eventType, source)
  encoded, _ := json.Marshal(payload)
  event, err := service.SessionEvents().Append(sessionID, taskID, eventType, encoded)
  h.deliverOrderedSessionEvent(service, event, h.orderedSessionRecipients(sessionID))
}

Browser conversation scope

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

The scope owns binding, snapshot tokens, ordered WS subscription, gap and poison recovery, and snapshot buffering.

Scope interface
export type ConversationScope = {
  pluginId: string;
  taskId: string;
  sessionId: string | null;
  signal: AbortSignal;
  ready(): Promise<OrderedReady>;
  renewContinuation(cursor: string, queryIdentity?: string): Promise<{ cursor: string; binding: OrderedReady }>;
  subscribe(listener: ConversationEventListener, kind: SnapshotKind, snapshotKey: SnapshotKey): () => void;
  subscribeRebind(listener: () => void): () => void;
  commitSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void;
  invalidateSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void;
  accept(event: RawSessionEvent): void;
  close(): void;
};
Ordered accept and buffering
accept(event: RawSessionEvent) {
  if (!isRawSessionEvent(event)) { this.blockForPoison(); return; }
  if (!this.acceptsScope(event, sessionId) || event.sequence < this.nextSequence) return;
  this.pendingBySequence.set(event.sequence, event);
  this.drainPending(sessionId);
}
private shouldBuffer(event: RawSessionEvent) {
  if (this.listeners.size === 0) return true;
  const eventKind = snapshotKindForEvent(event.event_type);
  for (const listener of this.listeners) {
    if (this.listenerSnapshotKinds.get(listener) !== eventKind) continue;
    const key = this.listenerSnapshotKeys.get(listener) ?? "";
    if (!this.committedSnapshots.has(`${eventKind}:${key}`)) return true;
  }
  return false;
}

Host facade hooks

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

The hooks fetch pages with binding and snapshot headers, merge pages, and project live ordered events.

Message page loader
async function fetchMessagePage({ scope, sessionId, taskId, authorsKey, sort, limit, cursor, binding }): Promise<MessagePage> {
  const params = new URLSearchParams({ sort, limit: String(limit) });
  if (taskId !== null) params.set("task_id", taskId);
  for (const author of authorsKey ? authorsKey.split(",") : []) params.append("author_type", author);
  if (cursor) params.set("cursor", cursor);
  const response = await fetch(pluginConversationUrl(scope.pluginId, `/conversation/task-sessions/${encodeURIComponent(sessionId)}/messages?${params}`), {
    credentials: "include",
    cache: "no-store",
    headers: { "X-Kandev-Plugin-Binding": binding.bindingToken, "X-Kandev-Snapshot-Token": binding.snapshotToken },
    signal: scope.signal,
  });
  return parseConversationResponse<MessagePage>(response);
}
Live projection
React.useEffect(() => {
  if (!scope || !sessionId) return;
  return scope.subscribe((event) => {
    if (event.event_type === "session.removed") {
      setState((current) => ({ ...current, removed: true, hasMore: false }));
      return true;
    }
    if (!event.event_type.startsWith("message.") || !eventMatchesTask(event, taskId)) return true;
    const message = messageFromEvent(event);
    if (!message) return false;
    setState((current) => {
      const messages = current.messages.filter((item) => item.id !== message.id);
      messages.push(message);
      messages.sort((left, right) => compareConversationMessages(left, right, sort));
      return { ...current, messages };
    });
    return true;
  }, "messages", snapshotKey);
}, [scope, sessionId, taskId, sort, snapshotKey]);

Public SDK and task-panel contract

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

The SDK adds conversation types and extends task-panel registration with title key, visibility, and scoped navigation.

SDK 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 PluginConversationApi {
  useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState;
  useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState;
  useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}
export interface PluginTaskPanelProps extends PluginTaskPanelContext {
  panelId: string;
  conversation: PluginTaskPanelConversationCapability;
}
Host wiring
export function buildHostApi(pluginId: string, storeApi: StoreApi<AppState>): PluginHostApi {
  return {
    pluginId,
    React,
    jsx: React.createElement,
    i18n: buildPluginI18nApi(pluginId),
    conversation: pluginConversationApi,
    ui: createPluginUIApi(pluginId),
    // ...
  };
}

Data and storage

New durable and wire types. Journal versions are immutable; live events carry the same sanitized payload.

FieldTypeNotes
conversationMessageDTOjsonid, sessionId, taskId, turnId, authorType, content (stripped), promptIndex, senderTaskId
conversationTurnDTOjsonid, sessionId, taskId, startedAt, completedAt, updatedAt
conversationTokenClaimsHMACbinding, cursor, snapshot, resume; generation, cutoff, fingerprint, expiry
SessionEventstructsession_id, sequence, event_id, protocol_version, event_type, task_id, payload
conversation_message_versionstablesession_id, message_id, row_sequence, payload, tombstone
conversation_turn_versionstablesession_id, turn_id, row_sequence, payload, tombstone
session_eventstablesession_id, sequence, event_id, payload, created_at

Risk

7 / 10 High
1 low5 medium10 high

Why this score

  • Touches auth, token signing, and WS protocol across backend and frontend.
  • Adds durable journal and event log with retention and poison recovery.
  • Large surface (174 files) but covered by new unit, integration, and E2E tests.

Trade-offs and review notes

Where to look first

  1. Check conversation_handlers.go for binding, snapshot, and cursor validation.
  2. Check conversation-scope.tsx for gap, poison, and rebind handling.
  3. Check conversation-host.tsx for pagination, merge, and removed state.
  4. Check conversation_journal.go for cutoff, tombstone, and sanitization.
  5. Check plugin-sdk types and host-api wiring for SDK parity.