본문 바로가기
프로젝트/AI 작업 모니터

Cursor 첫 답변이 작업 완료 뒤 맨 아래에 또 나온 이유, 사라진 임시 답변

by iwbap 2026. 10. 30.
728x90
반응형

Cursor 작업 완료 뒤 최종 답변 아래에 첫 답변 문장이 또 붙었다. 원인은 정식 답변이 저장된 뒤에도 임시 답변이 앱 캐시에 남은 것이었다. 최신 목록에서 사라진 항목만 지우고, 오래된 조회가 되살리지 못하게 막았다.

어두운 배경에 작업은 끝났는데 첫 답변이 맨 아래에 한 번 더 나왔다는 표지 문구와 목록에서 사라진 임시 답변은 캐시에서도 지운다는 부제, 임시 ID checkpoint:1이 정식 ID step:8:1로 바뀌고 늦게 온 조회 결과는 버전 번호로 걸러 되살리지 않는다는 설명이 있고, 오른쪽에는 작업 완료 표시 아래 첫 답변과 최종 답변, 그 밑에 점선 빨간 테두리로 표시된 중복 첫 답변이 놓인 휴대폰 화면 예시가 있는 대표 이미지
목록에서 사라진 임시 답변은 캐시에서도 지워야 한다

작업이 끝났는데 첫 문장이 맨 아래에 또 있었다

휴대폰에서 Cursor 대화를 열자 답변 순서가 이상했다. 첫 답변과 최종 답변 아래에 작업 상태를 확인 중이라는 문장이 또 나왔다.

서버에서 최신 메시지 100개를 다시 조회했다. 목록은 정식 첫 답변과 최종 답변으로 끝났고 중복 문장은 없었다. 서버에는 없고 앱에만 남은 문장이었다.

임시 답변은 저장되면 ID가 바뀐다

Cursor 기록을 읽는 쪽은 아직 저장되지 않은 응답을 checkpoint:<번호>라는 임시 assistant 메시지로 보낸다. 저장이 끝나면 같은 응답이 step:<turn>:<step>이라는 정식 ID로 바뀐다. 이때 임시 메시지는 최신 목록에서 그냥 사라진다.

앱 병합은 ID가 다르면 다른 메시지로 보고 계속 보관했다. 정식 답변은 새로 들어왔지만, 목록에서 빠진 임시 답변을 지울 근거가 없었다. 그래서 임시 첫 답변이 맨 끝에 남았다.

임시 답변이 저장되면 다른 ID로 바뀌는 흐름도, 저장 전 실시간 목록의 checkpoint:1 임시 assistant 메시지가 저장 완료 뒤 최신 목록에서는 step:8:1 첫 답변과 step:8:13 최종 답변으로 바뀌고 checkpoint:1은 사라지지만, 수정 전 앱 캐시는 ID가 다르면 다른 메시지로 보관해 checkpoint:1 첫 답변이 그대로 남았고 그 결과 서버 목록에는 없는 첫 답변 문장이 최종 답변 아래에 한 번 더 표시됐다는 설명

앞 글의 임시 도구 카드 정리는 tool 역할만 처리했다. 이번에는 assistant 메시지가 남았다.

사라진 것도 신호로 읽었다

먼저 임시 답변을 정확히 골라내는 조건을 만들었다. 역할이 assistant이고, ID가 checkpoint: 뒤에 숫자가 붙은 형태일 때만 해당한다.

private fun isCursorAssistantCheckpoint(message:ChatMessage) =
    message.role=="assistant"&&message.id.startsWith("checkpoint:")&&message.id.substringAfter(':').toIntOrNull()!=null

다음은 언제 이 목록을 믿을지였다. Cursor의 실시간 이벤트는 최신 꼬리 목록을 통째로 보낸다. 반면 live:로 시작하는 항목은 사용자 입력 하나만 바뀐 갱신이라, 거기 없다고 해서 사라졌다고 볼 수 없다.

// IDE live events contain the newest tail, while live:* entries are
// individual optimistic user updates and cannot prove anything absent.
val cursorTail=conversation.source=="cursor"&&messages.isNotEmpty()&&messages.none {it.id.startsWith("live:")||it.id.startsWith("local:")}
val next = remember(conversation.id, messages,cursorTail,true)

최신 목록으로 인정되면, 캐시에 있던 임시 답변 중 목록에 없는 것만 지운다. 문장 내용이 같다는 이유로는 지우지 않는다.

tails.compute(id) { _, previous ->
    val outdatedPage=pageRevision!=null&&pageRevision!=liveRevision(id)
    val safeMessages=if(discardCheckpoints||outdatedPage)messages.filterNot(::isCursorAssistantCheckpoint) else messages
    val stored=previous.orEmpty()
    val old=if(cursorTail&&!outdatedPage&&stored.any(::isCursorAssistantCheckpoint)) {
        val present=safeMessages.mapTo(hashSetOf()) {it.id}
        if(stored.any {isCursorAssistantCheckpoint(it)&&it.id !in present})
            stored.filterNot {isCursorAssistantCheckpoint(it)&&it.id !in present}
        else stored
    } else stored
    if(liveEvent)liveRevisions[id]=liveRevision(id)+1
    // … 이후 병합은 기존 mergeChatMessages 경로를 그대로 쓴다

정리 기준을 목록으로 묶으면 이렇다. 핵심은 목록 전체를 대표하는 응답일 때만 '없음'을 믿는다는 것이다.

목록에 없음을 언제 믿는지 정리한 도식, live:나 local: 항목이 없는 Cursor 실시간 최신 목록이면 목록에 없는 임시 답변을 삭제하고, live: 사용자 입력 갱신은 부재 판단에 쓰지 않으며, Codex 대화에는 Cursor 전용 정리를 적용하지 않고, 완료된 최신 페이지 재조회는 남은 임시 답변을 정리하며, 과거 페이지와 대기 중 캐시 페이지는 임시 답변을 되살리지 않고, 문장 내용이 같다는 이유로는 지우지 않는다는 문구가 아래에 있다

  • Cursor 실시간 최신 목록: 목록에 없는 임시 답변을 지운다.
  • live: 사용자 입력 갱신: 부재 판단에 쓰지 않는다.
  • Codex 대화: Cursor 전용 정리를 적용하지 않는다.
  • 완료된 최신 페이지 재조회: 남아 있던 임시 답변을 정리한다.
  • 과거 페이지나 대기 중인 캐시 페이지: 임시 답변을 되살리지 않는다.

늦게 도착한 조회 결과가 되살리지 않게

남은 문제는 순서였다. 대화를 다시 읽는 동안 실시간 갱신이 먼저 도착할 수 있다. 그러면 늦게 온 페이지가 새 임시 답변을 지우거나, 이미 지운 옛 임시 답변을 되살릴 수 있다.

그래서 대화마다 실시간 버전 번호를 두었다. 실시간 이벤트가 올 때마다 1씩 올리고, 페이지 조회는 시작할 때 번호를 기억한다. 페이지가 도착했을 때 번호가 다르면 오래된 페이지로 보고, 그 페이지의 임시 답변을 버리고 캐시 정리도 하지 않는다.

늦게 온 페이지를 버전 번호로 거르는 타임라인, 페이지 조회 시작 때 버전 1을 기억하고 실시간 이벤트가 도착해 버전이 2로 오른 뒤 옛 페이지가 도착하면 기억한 1과 현재 2가 달라 오래된 페이지로 판정하며, 이때 옛 페이지 속 임시 답변은 버려 이미 지운 임시 답변이 되살아나지 않고 캐시의 임시 답변 정리도 하지 않아 새 임시 답변을 지우지 않는다는 설명

수정 전에 실패하는 테스트부터

재현 테스트는 실제 상황을 옮겼다. 캐시에는 첫 답변, 아직 결과를 기다리는 도구, 임시 답변이 있다. 그 뒤 첫 답변과 최종 답변만 담긴 최신 목록이 도착한다.

private val intro=ChatMessage("step:8:1",9000002,"assistant","message","Reload status is being checked.\n\n\n","2026-10-30T00:14:18Z","")
private val answer=ChatMessage("step:8:13",9000014,"assistant","message","Everything is ready.","2026-10-30T00:14:42Z","")
private val checkpoint=intro.copy(id="checkpoint:1",ordinal=10000001,createdAt="0001-01-01T00:00:00Z")

@Test fun completedNativeTailRemovesTheDisappearedTemporaryReply() {
    val older=intro.copy(id="step:0:1",ordinal=1000002)
    val tool=ChatMessage("unmatched-tool",9000003,"tool","tool","Still awaiting its own result","","running")
    ChatLive.remember("chat",listOf(older,intro,tool,checkpoint))
    assertEquals(listOf(older,intro,tool,answer),receive(listOf(intro,answer)))
}

이 테스트는 수정 전에 실패했고, 수정 후 단위 테스트 166개가 통과했다. 결과 대기 도구 카드는 유지됐고, 에뮬레이터 화면 테스트 3개도 통과했다.

통합 직전 화면 회귀 18개도 통과했다. 채팅 읽기 14개, 임시 답변 중복 2개, 도구 완료 1개, 설정 1개였다. lint 오류는 0개였다.

  • 실시간 완료 때 중복된 첫 답변 제거
  • 새로고침으로 기존 캐시의 임시 답변 정리
  • 앞 글의 임시 도구 카드 정리 회귀

서버·확장 변경 없이 Android 2.4.17 앱만 수정해 업데이트가 필요하다. 실기기에서는 문제 화면까지만 확인했고, USB·디버깅 연결이 없어 수정 APK를 설치하지 못했다.

APK 다운로드 링크도 처음에는 앞 슬래시가 잘못되어 '다운로드할 수 없는 링크입니다'가 떴다. 정상 경로로 정정한 뒤 같은 휴대폰에서 다운로드 완료 알림을 확인했다. APK와 코드는 바꾸지 않았다.

FAQ

Q. 같은 문장이면 그냥 중복 제거하면 되지 않나?
A. 실제로 같은 문장을 두 번 말한 답변도 있다. 문장으로 지우면 진짜 기록이 사라진다.

Q. Codex 대화에는 왜 적용하지 않았나?
A. 임시 checkpoint: 답변은 Cursor 기록을 읽는 쪽의 형식이다. 다른 형식의 대화에 같은 규칙을 쓰면 근거 없이 지우게 된다.

이번에 배운 것

  • 목록에서 사라진 것도 정보다. 다만 목록 전체를 대표하는 응답일 때만 믿는다.
  • 내용이 같다는 것은 같은 메시지라는 증거가 아니다.
  • 비동기 조회는 시작 시점을 기억해야 늦게 온 결과를 걸러 낼 수 있다.

바로 앞 글 Cursor 작업이 끝났는데 도구 카드 3개가 계속 돈 이유, 두 개의 호출 ID와 이어지는 문제다. 캐시 갱신의 원자성은 Java 공식 문서 ConcurrentHashMap (compute)를 참고했다.

728x90
반응형