# heymoa-server ↔ heymoa-ai 채팅 SSE 구간. **계획 문서** — heymoa-ai 구현이 이 계약을 따른다.
# 이벤트 스키마는 여기서 정의하지 않고 asyncapi-web-server.yml을 $ref한다: server가 passthrough라
# 두 구간의 포맷이 같아야 하고, 실제 코드로 검증되는 쪽이 web 구간이기 때문이다.
asyncapi: 3.0.0
info:
  title: Heymoa Agent Chat SSE (server ↔ ai)
  version: 1.1.0
  description: |
    heymoa-server가 `POST /internal/v1/agent-chats/{chatId}/messages`로 유저 메시지를 보내면
    응답 본문이 SSE로 스트리밍된다. 요청 스키마는 openapi3-ai.yml에서 정의한다.

    producer는 heymoa-ai다. server는 이 스트림을 **변환 없이** web 구간
    (asyncapi-web-server.yml)으로 통과시키므로 이벤트 포맷이 두 구간에서 동일하다.

    heymoa-ai는 내부 전용 서비스다 — 브라우저가 이 채널에 직접 연결하지 않는다.

    ## server가 이 스트림을 소비하는 방식

    server는 이벤트 단위가 아니라 **입력 행(line) 단위**로 스트림을 읽는다. 행마다
    클라이언트 취소를 확인하고 공유 챗의 입력 잠금을 갱신한다. 그래서 comment만 흐르는 구간도
    server 입장에서는 "살아 있는" 신호이며, 반대로 **행이 60초간 오지 않으면 유휴로 판단해
    연결을 끊는다** (아래 keepalive).

    server가 스트림에서 뽑아 자기 DB에 저장(tee)하는 것은 셋이다.
    `message_end.content`(assistant 응답 전문), `tool_approval_resolved`(승인·거절 기록),
    `tool_call_result`(도구 실행 기록).

    ## keepalive — 15초 이하

    승인 대기(interrupt)처럼 이벤트 없이 스트림을 열어 둬야 하는 구간에서 heymoa-ai는
    SSE comment(`: keepalive`)를 **15초 이하 간격**으로 발행한다. server의 유휴 read
    timeout이 60초라 이보다 뜸하면 대기 중 스트림이 끊긴다.

    comment는 이벤트가 아니라 web으로 흘러가지 않는다 — server가 수신 시점에 소비한다.
    web 구간 heartbeat(20초 이하)는 이 간격과 **무관하다**: server가 독립 타이머로 발행하므로
    upstream이 조용해도 web 연결은 유지된다.

    ## 스트림은 반드시 종료 이벤트로 끝난다

    `message_end` 또는 `error` 없이 연결이 닫히면 server는 그 스트림을 **실패로 처리한다** —
    부분 응답을 저장하지 않고 web에 오류로 끝난 스트림을 내보낸다. 정상 경로에서 종료 이벤트를
    생략하면 안 된다.

    server는 종료 이벤트를 처리한 즉시 EOF를 기다리지 않고 읽기를 멈춘다. heymoa-ai가
    `message_end` 뒤에 연결을 열어 둬도 무방하다.
defaultContentType: application/json
servers:
  internal:
    host: heymoa-ai.internal
    protocol: https
    description: |
      내부 네트워크의 heymoa-ai 인스턴스 (public 노출 없음).
      **현재 이 방향에는 인증 헤더가 없다** — 네트워크 격리가 유일한 경계다.
      반대 방향(ai → server의 `/internal/**`)은 `X-Internal-Token`을 요구한다(APP-122).
      비대칭이며, 대칭 보호가 필요하면 openapi3-ai.yml의 같은 항목과 함께 결정한다.
channels:
  agentChatStream:
    servers:
      - $ref: '#/servers/internal'
    address: /internal/v1/agent-chats/{chatId}/messages
    title: agent 채팅 응답 스트림 (server가 요청, ai가 스트림 생산)
    description: |
      요청의 `toolCredentials`는 요청 스코프 메모리에서만 사용한다 —
      checkpoint·DB·로그 저장 금지, 양쪽 모두 로그 마스킹 필수.
    parameters:
      chatId:
        description: heymoa-server가 발급한 채팅 세션 id (13자 TSID)
        examples:
          - 0HZX2K7M9Q4AE
    messages:
      MessageStart:
        $ref: 'asyncapi-web-server.yml#/components/messages/MessageStart'
      Token:
        $ref: 'asyncapi-web-server.yml#/components/messages/Token'
      ToolCallStart:
        $ref: 'asyncapi-web-server.yml#/components/messages/ToolCallStart'
      ToolApprovalRequest:
        $ref: 'asyncapi-web-server.yml#/components/messages/ToolApprovalRequest'
      ToolApprovalResolved:
        $ref: 'asyncapi-web-server.yml#/components/messages/ToolApprovalResolved'
      ToolCallResult:
        $ref: 'asyncapi-web-server.yml#/components/messages/ToolCallResult'
      MessageEnd:
        $ref: 'asyncapi-web-server.yml#/components/messages/MessageEnd'
      Error:
        $ref: 'asyncapi-web-server.yml#/components/messages/Error'
operations:
  receiveAgentChatEvents:
    action: receive
    channel:
      $ref: '#/channels/agentChatStream'
    summary: heymoa-server가 agent 응답 이벤트를 SSE로 수신한다.
    description: |
      대화 full state는 heymoa-ai의 LangGraph checkpointer(`heymoa_ai` database)가 `chatId`
      기준으로 유지한다 — server가 히스토리를 다시 보낼 필요 없다.

      **단절 시 run 수명 (APP-142 확정)**: server가 이 SSE 연결을 끊으면(클라이언트 취소·
      read timeout·프로세스 종료) heymoa-ai는 진행 중인 run을 **취소하고 대기 중인 interrupt를
      폐기한다**. 따라서 그 뒤에 도착하는 승인 재개 요청은 **404**이고 도구는 실행되지 않는다.
      스트림이 끝난 뒤 도구만 실행돼 이력 없이 외부가 바뀌는 창은 존재하지 않는다.
      (근거: 승인 대기가 SSE 요청 코루틴 안에 있어 단절이 그 태스크를 취소하고,
      취소가 승인 레지스트리의 정리 경로를 태워 pending을 지운다.)

      **`approvalId`는 13자 TSID여야 한다.** server는 외부 식별자를 13자 TSID로 통일하며,
      형식이 다르면 승인 row 등록을 건너뛴다 — web에 카드는 뜨지만 승인 API가 404가 되고
      스트림은 상한(300초)까지 대기하다 REJECTED로 끝난다. 조용히 깨지는 경로이므로
      heymoa-ai 쪽 발급기를 이 형식에 맞춘다.

      **`toolCallId`는 시작 이벤트와 결과 이벤트에서 같아야 한다.** server는 이 값으로
      `tool_call_result`를 `tool_call_start`/`tool_approval_request`에 귀속시켜 도구 이름을
      기록한다. 짝이 없으면 `unknown`으로 남는다.
