diff --git a/src/lazy_enter/bridge.py b/src/lazy_enter/bridge.py index a806556..ae145c1 100644 --- a/src/lazy_enter/bridge.py +++ b/src/lazy_enter/bridge.py @@ -27,6 +27,7 @@ class Bridge: self._channel: str = self.config.allowed_channel_id self._active_target: str | None = None self._last_sent_output: str = "" + self._last_sent_fingerprint: str | None = None self._last_input_at = time.monotonic() self._last_output_at = time.monotonic() self._input_idle_reported = False @@ -45,6 +46,9 @@ class Bridge: if self.pty and self.pty.is_alive: self.pty.send(text) + # 입력 이후 출력은 동일 문자열이어도 한 번 더 전달한다. + self._last_sent_output = "" + self._last_sent_fingerprint = None self._last_input_at = time.monotonic() self._input_idle_reported = False logger.info("입력 전달: %s", text) @@ -99,6 +103,7 @@ class Bridge: self._channel = channel self._active_target = target self._last_sent_output = "" + self._last_sent_fingerprint = None self.pty = PtyManager( self._session_name_for_target(target), cli_name=target, @@ -131,6 +136,7 @@ class Bridge: self.pty = None self._active_target = None self._last_sent_output = "" + self._last_sent_fingerprint = None self.slack.send_message(channel, ":electric_plug: 세션 연결이 해제되었습니다.") def _poll_output(self) -> None: @@ -147,13 +153,7 @@ class Bridge: self._output_idle_reported = False if buffer: - # 메시지 길이 제한 적용 - message = buffer[: self.config.max_message_length].rstrip() - if len(buffer) > self.config.max_message_length: - message += "\n... (truncated)" - if message and message != self._last_sent_output: - self.slack.send_message(self._channel, f"```\n{message}\n```") - self._last_sent_output = message + self._send_output_chunks(buffer) buffer = "" output_idle = now - self._last_output_at @@ -191,6 +191,83 @@ class Bridge: # attach 프로세스가 예기치 않게 종료된 경우 self.slack.send_message(self._channel, ":warning: 세션 연결이 종료되었습니다.") + @staticmethod + def _split_message(text: str, max_length: int) -> list[str]: + """긴 텍스트를 메시지 길이 제한에 맞게 분할한다.""" + if max_length <= 0: + return [text] if text else [] + + lines = text.splitlines(keepends=True) + chunks: list[str] = [] + current = "" + + def flush() -> None: + nonlocal current + if current: + chunks.append(current) + current = "" + + for line in lines: + if len(line) > max_length: + flush() + start = 0 + while start < len(line): + piece = line[start : start + max_length] + chunks.append(piece) + start += max_length + continue + + if len(current) + len(line) > max_length: + flush() + current += line + + flush() + return chunks + + def _send_output_chunks(self, text: str) -> None: + """출력을 잘라서 Slack으로 순차 전송한다.""" + chunks = self._split_message(text, self.config.max_message_length) + snapshot = "\n\x00".join(chunks) + fingerprint = self._output_fingerprint(snapshot) + if not chunks: + return + if ( + self._last_sent_fingerprint is not None + and fingerprint == self._last_sent_fingerprint + ): + return + + for chunk in chunks: + if chunk.endswith("\n"): + message = f"```\n{chunk}```" + else: + message = f"```\n{chunk}\n```" + self.slack.send_message(self._channel, message) + self._last_sent_output = snapshot + self._last_sent_fingerprint = fingerprint + + @staticmethod + def _output_fingerprint(text: str) -> str: + """중복 전송 억제를 위한 정규화 지문을 생성한다.""" + normalized_lines: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.rstrip() + if not line.strip(): + continue + + # tmux 상태줄의 시계/날짜 라인은 프레임마다 변할 수 있어 제외한다. + if re.fullmatch( + ( + r'\s*\w*odex\]\s+\d+:[^\[]*' + r'\[\d+,\d+\]\s+".+"\s+\d{2}:\d{2}\s+\d{2}-[A-Za-z]{3}-\d{2}' + ), + line, + ): + continue + + normalized_lines.append(line) + return "\n".join(normalized_lines) + def run(self) -> None: """브릿지를 시작한다.""" logging.basicConfig( diff --git a/tests/test_bridge.py b/tests/test_bridge.py index b9dfa73..8c15172 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -45,6 +45,7 @@ class FakePtyManager: self.session_name = session_name self.cli_name = cli_name self._alive = False + self.sent_inputs: list[str] = [] FakePtyManager.instances.append(self) @property @@ -57,8 +58,8 @@ class FakePtyManager: def stop(self) -> None: self._alive = False - def send(self, _text: str) -> None: - return None + def send(self, text: str) -> None: + self.sent_inputs.append(text) def read_output(self, timeout: int = 5) -> str: return "" @@ -104,6 +105,18 @@ def test_start_codex_routes_to_codex_session(monkeypatch) -> None: ) +def test_start_session_resets_output_dedup_state(monkeypatch) -> None: + FakePtyManager.instances.clear() + bridge = _make_bridge(monkeypatch) + bridge._last_sent_output = "stale" + bridge._last_sent_fingerprint = "stale-fp" + + bridge._handle_command("start", "codex", "C1") + + assert bridge._last_sent_output == "" + assert bridge._last_sent_fingerprint is None + + def test_unknown_target_is_rejected(monkeypatch) -> None: bridge = _make_bridge(monkeypatch) @@ -113,3 +126,174 @@ def test_unknown_target_is_rejected(monkeypatch) -> None: "C1", ":warning: 지원하지 않는 대상입니다.", ) + + +def test_handle_message_resets_last_sent_output_after_input(monkeypatch) -> None: + FakePtyManager.instances.clear() + bridge = _make_bridge(monkeypatch) + + bridge._handle_command("start", "codex", "C1") + pty = FakePtyManager.instances[-1] + bridge._last_sent_output = "previous output" + + bridge._handle_message("status", "C1") + + assert pty.sent_inputs[-1] == "status" + assert bridge._last_sent_output == "" + assert bridge._last_sent_fingerprint is None + + +def test_split_message_preserves_all_content(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + chunks = bridge._split_message("line1\nline2\nline3", max_length=7) + assert "".join(chunks) == "line1\nline2\nline3" + + +def test_split_message_preserves_trailing_whitespace_and_blank_line( + monkeypatch, +) -> None: + bridge = _make_bridge(monkeypatch) + text = "ab \n\ncd " + chunks = bridge._split_message(text, max_length=4) + assert "".join(chunks) == text + + +def test_send_output_chunks_sends_multiple_messages(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge._send_output_chunks("1234567890") + + assert bridge.slack.sent_messages == [ + ("C1", "```\n1234567890\n```"), + ] + + bridge.config.max_message_length = 4 + bridge.slack.sent_messages.clear() + bridge._send_output_chunks("abcdefghij") + assert bridge.slack.sent_messages == [ + ("C1", "```\nabcd\n```"), + ("C1", "```\nefgh\n```"), + ("C1", "```\nij\n```"), + ] + + +def test_send_output_chunks_skips_duplicate_snapshot(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 4 + + bridge._send_output_chunks("abcdefghij") + first = list(bridge.slack.sent_messages) + + bridge._send_output_chunks("abcdefghij") + assert bridge.slack.sent_messages == first + + +def test_send_output_chunks_skips_duplicate_with_volatile_status_line( + monkeypatch, +) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 1000 + + first_output = ( + "Would you like to make the following edits?\n" + "src/main.py (+68 -1)\n" + 'odex] 0:node* [0,0] "jihoson-home" 05:12 17-Feb-26\n' + "Press enter to confirm or esc to cancel\n" + ) + second_output = ( + "Would you like to make the following edits?\n" + "src/main.py (+68 -1)\n" + 'odex] 0:node* [0,0] "jihoson-home" 05:13 17-Feb-26\n' + "Press enter to confirm or esc to cancel\n" + ) + + bridge._send_output_chunks(first_output) + first = list(bridge.slack.sent_messages) + + bridge._send_output_chunks(second_output) + assert bridge.slack.sent_messages == first + + +def test_send_output_chunks_sends_first_when_fingerprint_is_empty(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + volatile_only = 'odex] 0:node* [0,0] "jihoson-home" 05:12 17-Feb-26\n' + + bridge._send_output_chunks(volatile_only) + assert bridge.slack.sent_messages == [("C1", f"```\n{volatile_only.rstrip()}\n```")] + + bridge._send_output_chunks(volatile_only) + assert bridge.slack.sent_messages == [("C1", f"```\n{volatile_only.rstrip()}\n```")] + + +def test_send_output_chunks_keeps_non_tmux_timestamp_lines(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 1000 + + first_output = ( + "report generated at 05:12 17-Feb-26\n" + "count=10\n" + ) + second_output = ( + "report generated at 05:13 17-Feb-26\n" + "count=10\n" + ) + + bridge._send_output_chunks(first_output) + bridge._send_output_chunks(second_output) + + assert bridge.slack.sent_messages == [ + ("C1", f"```\n{first_output.rstrip()}\n```"), + ("C1", f"```\n{second_output.rstrip()}\n```"), + ] + + +def test_send_output_chunks_preserves_whitespace_signals(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 1000 + + first_output = "def f():\n return 1\n" + second_output = "def f():\n return 1\n" + + bridge._send_output_chunks(first_output) + bridge._send_output_chunks(second_output) + + assert bridge.slack.sent_messages == [ + ("C1", f"```\n{first_output.rstrip()}\n```"), + ("C1", f"```\n{second_output.rstrip()}\n```"), + ] + + +def test_send_output_chunks_keeps_standalone_time_lines(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 1000 + + bridge._send_output_chunks("[5:12 AM]\n") + bridge._send_output_chunks("[5:13 AM]\n") + + assert bridge.slack.sent_messages == [ + ("C1", "```\n[5:12 AM]\n```"), + ("C1", "```\n[5:13 AM]\n```"), + ] + + +def test_send_output_chunks_keeps_non_tmux_status_like_lines(monkeypatch) -> None: + bridge = _make_bridge(monkeypatch) + bridge._channel = "C1" + bridge.config.max_message_length = 1000 + + first_output = '[2,3] "job-runner" 05:12 17-Feb-26\n' + second_output = '[2,3] "job-runner" 05:13 17-Feb-26\n' + + bridge._send_output_chunks(first_output) + bridge._send_output_chunks(second_output) + + assert bridge.slack.sent_messages == [ + ("C1", "```\n[2,3] \"job-runner\" 05:12 17-Feb-26\n```"), + ("C1", "```\n[2,3] \"job-runner\" 05:13 17-Feb-26\n```"), + ]