feat: add GET /ai/sessions/:id/messages returning a session transcript hydrated into the client turn and block shape plus any pending approval, so a reopened assistant conversation rehydrates

This commit is contained in:
Matthew Meszaros
2026-07-15 17:54:16 +02:00
parent 22edb6f445
commit 81bfb41d4e
3 changed files with 114 additions and 0 deletions
+34
View File
@@ -109,6 +109,40 @@ func (h *Handler) ListAgentSessions(c *gin.Context) {
})
}
// AgentSessionMessages — GET /ai/sessions/:id/messages returns a session's
// hydrated transcript (+ any pending approval) so a reopened tab rehydrates.
func (h *Handler) AgentSessionMessages(c *gin.Context) {
if h.AIAgentService == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
return
}
inv, xerr := h.jwtInvocation(c)
if xerr != nil {
errx.JSON(c, xerr)
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid session id"))
return
}
sess, gerr := h.AIAgentService.GetSession(c.Request.Context(), inv.OrgID, inv.UserID, sessionID)
if gerr != nil || sess == nil {
errx.JSON(c, errx.New(errx.NotFound, "session not found"))
return
}
turns, terr := h.AIAgentService.Transcript(c.Request.Context(), inv.OrgID, inv.UserID, sessionID)
if terr != nil {
errx.JSON(c, terr)
return
}
c.JSON(http.StatusOK, gin.H{
"title": sess.Title,
"turns": turns,
"pending": sess.Context.Pending,
})
}
// AgentMessage — POST /ai/sessions/:id/messages (SSE)
func (h *Handler) AgentMessage(c *gin.Context) {
if h.AIAgentService == nil {
+1
View File
@@ -879,6 +879,7 @@ func Run(
{
ai.POST("/sessions", h.CreateAgentSession)
ai.GET("/sessions", h.ListAgentSessions)
ai.GET("/sessions/:id/messages", h.AgentSessionMessages)
ai.POST("/sessions/:id/messages", h.AgentMessage)
ai.POST("/sessions/:id/approve", h.AgentApprove)
+79
View File
@@ -96,6 +96,11 @@ type Service interface {
ListSessions(ctx context.Context, orgID, userID uuid.UUID, limit int, beforeCreatedAt time.Time, beforeID uuid.UUID) ([]models.AgentSession, error)
GetSession(ctx context.Context, orgID, userID, sessionID uuid.UUID) (*models.AgentSession, error)
// Transcript returns the session's persisted history hydrated into the same
// turn/block shape the live stream renders, so a reopened tab looks identical
// to a fresh run.
Transcript(ctx context.Context, orgID, userID, sessionID uuid.UUID) ([]HydratedTurn, *errx.Error)
// RunMessage streams a new user message's run. inv carries the caller's
// identity + org permission bits. emit is called for each SSE step.
RunMessage(ctx context.Context, inv aitools.Invocation, sessionID uuid.UUID, messageID, text, page, resource string, emit func(StreamEvent)) *errx.Error
@@ -138,6 +143,80 @@ func (s *service) GetSession(ctx context.Context, orgID, userID, sessionID uuid.
return s.repo.GetSession(ctx, orgID, userID, sessionID)
}
// HydratedBlock is one rendered piece of a persisted turn, mirroring the
// client's live block model (text or tool step) so a reopened session renders
// identically to a live one.
type HydratedBlock struct {
Kind string `json:"kind"` // "text" | "tool"
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
ArgsSummary string `json:"args_summary,omitempty"`
Result string `json:"result,omitempty"`
EntityType string `json:"entity_type,omitempty"`
EntityID string `json:"entity_id,omitempty"`
OpenURL string `json:"open_url,omitempty"`
Done bool `json:"done"`
}
// HydratedTurn is one user or assistant turn rebuilt from the transcript.
type HydratedTurn struct {
Role string `json:"role"` // "user" | "assistant"
Blocks []HydratedBlock `json:"blocks"`
}
func (s *service) Transcript(ctx context.Context, orgID, userID, sessionID uuid.UUID) ([]HydratedTurn, *errx.Error) {
msgs, xerr := s.loadTranscript(ctx, orgID, userID, sessionID)
if xerr != nil {
return nil, xerr
}
return hydrateTranscript(msgs), nil
}
// hydrateTranscript folds the stored provider-agnostic messages into the
// client's turn/block model. Consecutive assistant + tool messages collapse into
// one assistant turn (interleaved text and tool steps), matching how the live
// SSE loop appends events to the current assistant turn until the next user
// message.
func hydrateTranscript(msgs []generation.AgentMessage) []HydratedTurn {
turns := make([]HydratedTurn, 0, len(msgs))
// tool_call_id -> (turnIndex, blockIndex) so a tool result completes its step.
loc := map[string][2]int{}
ensureAssistant := func() int {
if len(turns) == 0 || turns[len(turns)-1].Role != "assistant" {
turns = append(turns, HydratedTurn{Role: "assistant"})
}
return len(turns) - 1
}
for _, m := range msgs {
switch m.Role {
case "user":
turns = append(turns, HydratedTurn{Role: "user", Blocks: []HydratedBlock{{Kind: "text", Text: m.Content, Done: true}}})
case "assistant":
ti := ensureAssistant()
if strings.TrimSpace(m.Content) != "" {
turns[ti].Blocks = append(turns[ti].Blocks, HydratedBlock{Kind: "text", Text: m.Content, Done: true})
}
for _, tc := range m.ToolCalls {
turns[ti].Blocks = append(turns[ti].Blocks, HydratedBlock{Kind: "tool", Tool: tc.Name, ArgsSummary: summarizeArgs(tc.Args)})
loc[tc.ID] = [2]int{ti, len(turns[ti].Blocks) - 1}
}
case "tool":
at, ok := loc[m.ToolCallID]
if !ok {
continue
}
ev := toolResultEvent(turns[at[0]].Blocks[at[1]].Tool, m.Content)
b := &turns[at[0]].Blocks[at[1]]
b.Done = true
b.Result = ev.Result
b.EntityType = ev.EntityType
b.EntityID = ev.EntityID
b.OpenURL = ev.OpenURL
}
}
return turns
}
// errOutOfCredits / errCapExceeded are recorded by the credit PreIteration hook
// so the run can stop cleanly and the reason surfaced to the client.
var (