ConversationTurnService

ConversationTurnService is the only entry point for executing a conversation turn — whether triggered by the SSE chat endpoint, an IM @AI mention, a group-chat agent reply, or an Aura Connect Protocol (ACP) run. Per red line §12, every chat implementation must call runTurn or resumeTurn. Side-channel SSE/WebSocket frame writers, ad-hoc persistence, or hand-rolled broadcast loops are prohibited; the chokepoint exists so that turn audit, outbox publishing, cost accounting, approval gates, and resume semantics are uniform.

Concept

A "turn" is one user message → one agent response cycle, possibly with tool calls and interrupts. The service:

  1. Persists the user message inside the turn transaction.
  2. Resolves the conversation, agent, model, and tool list.
  3. Enforces cost limit, concurrency guard, approval gate.
  4. Drives the model + tool loop, streaming deltas to a ResponseSink.
  5. Publishes turn-lifecycle events to the outbox.
  6. On interrupt (approval pending / tool error / cancel), persists state for resumeTurn.

Streaming destination is abstracted by ResponseSinkSseResponseSink for HTTP SSE, BroadcastResponseSink for IM/group chat. Plugins implementing custom chat surfaces ship a new sink, never a new turn driver.

Interface signature

package com.auraboot.framework.conversation;

public interface ConversationTurnService {

    /** Start a new turn. Returns a sealed TurnOutcome
     *  (Success / Interrupted / PendingConfirmation / Failed). */
    TurnOutcome runTurn(TurnRequest request, ResponseSink sink);

    /** Resume a confirm-suspended turn (after approval / denial / cancel). */
    TurnOutcome resumeTurn(String pendingTurnId, ConfirmDecision decision, ResponseSink sink);

    enum ConfirmDecision { APPROVED, DENIED, CANCELLED }
}

TurnRequest is a record carrying tenantId, userId, humanMemberId, channel, agentCode, conversationId, clientMsgId, userMessage, pageContext (Map), options (Map), inboundMode, and similar fields. resumeTurn looks up the suspended turn state by pendingTurnId and applies the ConfirmDecision (approve / deny / cancel); it does not create a new inbound message.

Implementation example

A custom HTTP endpoint that exposes a tenant-internal chat surface to a third-party UI:

package com.acme.chat.controller;

import com.auraboot.framework.conversation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.MediaType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/api/acme/chat")
public class AcmeChatController {

    @Autowired private ConversationTurnService turns;
    @Autowired private ObjectMapper objectMapper;

    @PostMapping(path = "/stream",
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestBody TurnRequest req) {
        SseEmitter emitter = new SseEmitter(0L);
        // SseResponseSink(SseEmitter, ObjectMapper) — platform-provided SSE adapter
        ResponseSink sink  = new SseResponseSink(emitter, objectMapper);
        // runTurn drives the loop; returns a TurnOutcome synchronously when the
        // turn either completes or suspends for a confirm.
        turns.runTurn(req, sink);
        return emitter;
    }

    @PostMapping(path = "/{pendingTurnId}/resume",
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter resume(@PathVariable String pendingTurnId,
                             @RequestParam ConversationTurnService.ConfirmDecision decision) {
        SseEmitter emitter = new SseEmitter(0L);
        ResponseSink sink  = new SseResponseSink(emitter, objectMapper);
        // resumeTurn restores the suspended state by pendingTurnId and continues per ConfirmDecision
        turns.resumeTurn(pendingTurnId, decision, sink);
        return emitter;
    }
}

What this does not do (and you must not do):

  • Persist messages directly to ab_im_message.
  • Write SSE frames after runTurn returns.
  • Re-emit outbox events.
  • Touch the cost meter, approval table, or concurrency semaphore.

All of these happen inside runTurn / resumeTurn.

Registration

Host-managed singleton bean — @Autowired it. There is no SPI here; if you need to extend behavior, extend the agent (tools, prompt, model) or the sink (transport), not the turn driver.

Common pitfalls

  • Bypassing for "simple" use cases. Even a one-shot, no-tool agent must go through runTurn — the outbox events and approval gate matter for audit.
  • Writing SSE frames after runTurn returns. The sink owns the connection; double-writing breaks the stream and leaks state.
  • Hand-rolling resume. resumeTurn carries the entire interrupt state — never reconstruct from the conversation log.
  • Tool errors swallowed in custom tools. Throw — runTurn converts to a tool-error step the model can observe and retry.

Related