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

작업 중 메시지는 예약으로, 입력창 버튼 하나가 녹음·전송·중단을 오갔다

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

휴대폰 앱으로 IDE의 AI 에이전트에게 일을 시키면 작업 도중에 한 줄 더 말하고 싶어진다. 이번에는 작업 중 보낸 메시지를 기본으로 '예약'하고, 입력창 버튼 하나가 상황에 따라 음성 입력·전송·중단을 오가게 바꿨다. Codex는 원본 큐와 steer를, Cursor는 stop Hook을 썼다.

어두운 배경에 작업 중 메시지는 예약으로, 입력창 버튼 하나가 녹음과 전송과 중단을 오갔다는 표지 문구와 결과를 모르면 다시 보내지 않고 글을 돌려준다는 부제, Codex는 원본 큐에 예약하고 지금 반영은 같은 실행에 steer, Cursor는 stop Hook으로 작업 뒤 한 건을 보내고 취소와 전달은 먼저 잡은 쪽만 한다는 설명이 있고, 오른쪽에는 에이전트 응답 생성 중 말풍선 아래 예약됨 작업 후 전송 줄과 취소, 지금 반영 버튼, 메시지 입력창 옆 네모 모양 중단 버튼이 놓인 휴대폰 화면 예시가 있는 대표 이미지
작업 중에 보낸 말은 끼어들지 않고 줄을 선다

작업 중에 한 줄 더 보내고 싶을 때

예전 입력창 코드에서는 주 버튼이 음성 입력과 전송을 맡았다. 입력이 비면 음성 입력, 글이 있으면 전송하는 식이었다. 작업 중 멈추는 동작은 그 버튼에 없었다.

작업 중 전송은 별도 모드 버튼 없이 기본 예약으로 정했다. 전달 시점은 IDE별로 다르게 처리했다.

수정은 2026년 10월 28일 23시 57분(KST) 커밋에 들어갔다. 첫 화면은 Android 2.3.15, 예약 개별 취소와 Codex 즉시 반영은 2.3.16과 IDE 확장 0.4.25에서 붙였다.

입력창 버튼 하나가 세 가지 일을 한다

입력창이 비어 있고 쉬는 중이면 음성 입력, 글이나 첨부가 있으면 전송이다. 작업이 진행 중인데 입력이 비어 있으면 중단 버튼이 된다.

val hasDraft = text.isNotBlank() || attachments.isNotEmpty()
val stopMode = (stopping || runState?.active==true || workInProgress) && !hasDraft && !voiceInProgress
val canSend = available && !sending && !stopping && !voiceInProgress && hasDraft && !tooLong
val voiceMode = !hasDraft && !sending && !stopMode
// … 주 버튼: 작업 중+입력 없음이면 중단, 비어 있으면 음성, 그 밖에는 전송
onClick = {when {stopMode->onStop?.invoke();voiceMode->onVoiceInput();else->onSend()}},
// … 초안이 있는데 작업 중이면 중단은 상태 줄의 작은 버튼으로
val showDraftStop=hasDraft&&(runState?.active==true||workInProgress||stopping)&&!voiceInProgress

작업 중에 글을 쓰고 있으면 주 버튼은 전송으로 남는다. 중단은 상태 줄 오른쪽의 작은 '중단' 버튼으로 옮겨 가고, 쓰던 초안과 첨부는 그대로 남는다.

앱 입력창의 주 버튼이 입력창 상태에 따라 역할을 바꾸는 것을 세 칸으로 정리한 도식, 입력이 없고 작업도 없으면 마이크 아이콘의 음성 입력, 글이나 첨부가 있으면 위쪽 화살표의 전송이고 작업 중이면 예약 모드 버튼 없이 작업 후 예약으로 보내며, 입력이 없는데 작업이 진행 중이면 네모 아이콘의 중단 버튼이 되어 실행 ID를 다시 확인한 뒤 같은 실행만 중단한다, 아래에는 작업 중에 글을 쓰는 동안 주 버튼은 전송으로 남고 중단은 상태 줄의 작은 버튼으로 옮겨 가며 초안과 첨부는 유지된다는 설명이 있다

Codex는 원본 큐에 줄 세우고, 지금 반영은 steer로

앱은 전송 방식을 queue로 지정해 Codex app-server의 원본 큐(thread/queue)에 맡겼다. Codex 큐는 기존 동작대로 턴 완료·실패 뒤 예약 메시지를 순서대로 실행한다. 입력창 위에는 '예약됨' 줄이 보인다.

예약에는 '취소'와 '지금 반영'이 있다. 먼저 큐에서 지우고 deleted: true를 확인한 경우에만 다음 단계로 간다. 다른 클라이언트가 가져간 항목은 다시 보내지 않는다.

// Deletion is the ownership claim. If another client already consumed the
// entry, it must never be steered or submitted again.
if err = c.broker.call(r.Context(), "thread/queue/delete", map[string]any{"threadId": thread, "queuedSubmissionId": id}, &deleted); err != nil || !deleted.Deleted {
    http.Error(w, "queue cancellation was not confirmed; refresh before retrying", 409)
    return
}
// … 지금 반영이면 실행 ID를 다시 확인한 뒤
// A removed clientUserMessageId must not be reused. Keep the exact native
// input (including attachments), bound to the original active turn.
e = c.broker.call(r.Context(), "turn/steer", map[string]any{"threadId": thread, "expectedTurnId": run, "input": target.Input}, &accepted)
if e != nil || accepted.TurnID != run {
    action.Status = "unknown"
    action.Error = "예약은 제거됐지만 지금 반영 결과를 확인하지 못했습니다. 자동으로 다시 보내지 않습니다"
}

지금 반영은 같은 실행 ID를 다시 확인한 뒤 turn/steer로 진행 중인 작업에 내용을 넣는다. 원래 입력을 그대로 넘기니 첨부도 함께 간다. 그사이 작업이 바뀌었으면 예약만 취소했다고 알린다.

steer 결과를 확인하지 못하면 unknown으로 남기고 자동으로 다시 보내지 않는다. 두 경우 모두 입력창이 비어 있으면 앱이 그 글을 되돌려 준다.

Codex 예약 메시지를 취소하거나 지금 반영할 때의 흐름도, 원본 대화 큐에 있는 예약 메시지를 먼저 큐에서 삭제하고 deleted true를 확인하며 확인에 실패하면 아무것도 보내지 않고 새로 고침을 안내한다, 확인되면 취소는 cancelled로 끝나고 지금 반영은 같은 실행 ID를 다시 확인한 뒤 turn/steer로 넣어 submitted가 되며, 결과가 불확실하면 unknown으로 남기고 다시 보내지 않고 작업이 바뀌었으면 예약만 취소했다고 알린다, 아래에는 결과가 불확실하거나 작업이 바뀌면 입력창이 비어 있을 때 앱이 원래 글을 되돌려 준다는 설명이 있다

Cursor에는 큐 대신 stop Hook

Cursor는 원본 큐 대신 앱 연결에서 예약을 만들었다. 공식 stop Hook의 followup_message로 같은 generation(응답 실행)이 완료된 뒤 한 건을 후속 메시지로 보낸다.

예약은 대화당 한 건이며 유효 시간은 30분이다. 확장은 계획 파일과 메시지를 저장한다. 문제는 취소와 Hook이 같은 글을 동시에 가져갈 수 있다는 점이었다.

그래서 취소와 Hook이 같은 consumed.json 파일을 '없을 때만 만들기'로 생성하게 했다. 먼저 만든 쪽만 전달하거나 취소할 수 있다. 늦은 쪽은 파일이 이미 있으니 아무것도 하지 않는다.

Cursor 예약 메시지를 두고 사용자 취소와 stop Hook이 경합하는 상황을 그린 도식, 대화당 한 건이고 30분 동안 유효한 예약 폴더에 계획 파일과 메시지가 있고, 왼쪽의 사용자 취소나 중단 버튼과 오른쪽의 같은 대화와 같은 실행 ID에서 completed로 끝난 stop Hook이 모두 가운데의 consumed.json을 없을 때만 만들려고 시도한다, 먼저 만든 쪽이 취소라면 예약을 끄고 메시지를 지워 취소되고 Hook이라면 followup_message로 다음 메시지 한 건을 전달하며 파일이 이미 있으면 늦은 쪽은 아무것도 하지 않는다, 아래에는 completed가 아니면 예약을 끄고 메시지를 지운다는 설명이 있다

private async cancelPlan(dir:string):Promise<boolean>{
    // The stop hook uses this same exclusive marker before reading the
    // prompt. Exactly one side owns delivery or cancellation.
    try{await fs.writeFile(path.join(dir,"consumed.json"),JSON.stringify({cancelled:true}),{flag:"wx",mode:0o600})}
    catch(error){if((error as NodeJS.ErrnoException).code==="EEXIST")return false;throw error}
    await fs.writeFile(path.join(dir,"disabled"),"");
    await fs.rm(path.join(dir,"prompt.txt"),{force:true});
    return true;
}

Hook은 같은 대화·실행 ID·작업 폴더의 예약만 집는다. completed가 아니면 예약을 끄고 메시지를 지운다.

// A queued follow-up never blocks tools or changes the IDE's global queue
// preference. It is consumed once, only by the exact run's completed stop hook.
// … 같은 대화·같은 실행 ID·같은 작업 폴더인 예약만 남긴 뒤
if event.Status != "completed" {
    _ = submitJSON(filepath.Join(dir, "disabled"), map[string]string{"status": event.Status})
    _ = os.Remove(filepath.Join(dir, "prompt.txt"))
    continue
}
if err := submitJSON(filepath.Join(dir, "consumed.json"), map[string]string{"generation_id": event.GenerationID}); err != nil {
    return nil, nil
}
// … 메시지를 읽고 길이·해시가 계획과 맞을 때만
return map[string]any{"followup_message": string(prompt)}, nil

중단은 같은 실행인지 확인한 뒤에만

중단은 선택한 대화와 실행 ID를 다시 확인하고, 실행이 바뀌면 거절한다. Cursor는 composer.cancelChat, Codex는 turn/interrupt를 호출한다. Cursor에서 중단하면 해당 실행의 예약도 취소한다.

const current=await selection.verify(id,false);
const live=await this.state(id);
if(current.generation_id!==run||!this.running(current.status,live)||live.run_id!==run)throw Error("실행이 바뀌거나 종료됐습니다. 다른 작업을 중단하지 않았습니다");
for(const pending of await this.plans())if(pending.plan.conversation_id===id&&pending.plan.generation_id===run)await this.cancelPlan(pending.dir);
if(await selection.generation(id)!==run)throw Error("실행이 바뀌어 중단하지 않았습니다");
await this.host.execute("composer.cancelChat",id);

Cursor 3.27은 실행 중인 작업도 마지막 저장 전까지 디스크 상태를 aborted로 남긴다. 같은 generation의 Hook 기록이 열려 있으면 진행 중으로 본다. 대화 본문으로 실행 여부를 짐작하지는 않는다.

검증은 아래처럼 했다. 같은 커밋의 사용 한도 예약, 갱신 최적화, 도구 카드 수정은 다음 글들에서 따로 다룬다.

  • 서버·확장 헬퍼 Go 테스트 전체와 확장 Node 테스트 110개
  • Android 단위 테스트 127개, 최종 에뮬레이터 테스트 12개
  • 새 UI 테스트 시나리오: Codex·Cursor 모두 기본 예약, 중단 뒤 초안 유지, 결과가 불확실한 지금 반영은 다시 보내지 않고 글만 복구

이번에 배운 것

  • 모드 버튼을 늘리기보다 상태에 맞는 기본값을 정한다.
  • 예약 메시지는 지운 쪽, 먼저 만든 쪽이 주인이다.
  • 결과를 모르면 다시 보내지 말고 글을 돌려준다.

다음 할 일은 Cursor에서도 지금 반영을 쓸 수 있는 공식 경로가 생기는지 지켜보는 것이다.

자주 묻는 질문

Q. Cursor에서는 왜 '지금 반영'이 없나?
A. 앱 연결에서 즉시 반영 경로를 확인하지 못해서다. Cursor는 현재 작업이 끝난 뒤 stop Hook으로 한 건만 이어서 보낸다.

Q. 예약을 취소하는 순간 작업이 끝나면 메시지가 두 번 가나?
A. 아니다. 취소와 Hook 중 consumed.json을 먼저 만든 쪽만 동작한다.

관련 글: Cursor Hook으로 전송 추적, 버튼 없는 전송은 실험으로 남겼다 (Cursor Hook을 다룬 글), 채팅 전송 '실행 중' 멈춤, 대화 읽기 전에 '안 보냄'부터 기록했다 (바로 앞 글)
참고: Cursor 공식 문서, Hooks(stop과 followup_message) · OpenAI Codex 공식 문서, App Server(turn/steer와 turn/interrupt)

728x90
반응형