From 81bfb41d4e04fbcfd7d85d8e35b63a83f583342e Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 15 Jul 2026 17:54:16 +0200 Subject: [PATCH] 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 --- internal/api/handler/ai_agent.go | 34 ++++++++++++++ internal/api/routes.go | 1 + internal/app/aiagent/service.go | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/internal/api/handler/ai_agent.go b/internal/api/handler/ai_agent.go index 114c0115..4367f571 100644 --- a/internal/api/handler/ai_agent.go +++ b/internal/api/handler/ai_agent.go @@ -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 { diff --git a/internal/api/routes.go b/internal/api/routes.go index 7e65cd47..ec009a25 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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) diff --git a/internal/app/aiagent/service.go b/internal/app/aiagent/service.go index e8cc5dce..19c69947 100644 --- a/internal/app/aiagent/service.go +++ b/internal/app/aiagent/service.go @@ -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 (