diff --git a/mydocs/report/edit_demo_4854/README.md b/mydocs/report/edit_demo_4854/README.md new file mode 100644 index 0000000000..849809c5b7 --- /dev/null +++ b/mydocs/report/edit_demo_4854/README.md @@ -0,0 +1,20 @@ +# [#4854] 전/후 증빙 — 세션 검색의 이어보기 + +`cursor-before-after.png` 는 **같은 문서·같은 검색어·같은 창 크기로 똑같은 요청 3건**을 +두 바이너리에 던진 결과다. + +- 문서: `samples/hwp3-sample.hwp` · 검색어 `"의"` · 전체 매치 276건 · `maxMatches=3` +- BEFORE: `upstream/devel` `627c8c49a` 와 `src/mcp_serve.rs`·`src/main.rs` 가 동일한 빌드 +- AFTER: 이 브랜치 빌드 + +| | offset=0 | offset=3 | offset=6 | 도달 | +|---|---|---|---|---| +| BEFORE | 앞 3건 | **같은 앞 3건** | **같은 앞 3건** | 3 / 276 | +| AFTER | 1~3번째 | 4~6번째 | 7~9번째 | 276 / 276 | + +BEFORE 는 `offset` 을 모르므로 값을 바꿔도 응답이 같고 `nextOffset` 도 없다 — 루프가 1홉에서 +끝나 나머지 273건은 이 도구로 도달할 수단이 없다. AFTER 는 창이 매 홉 전진하고, +`nextOffset` 이 사라질 때까지 따라가면 전수에 닿는다. + +재현은 `tests/mcp_result_cursor_contract.rs` 가 그대로 한다(창 1·2·3·7 에서 이어 붙인 결과가 +전수와 정확히 일치하는지까지 검사). diff --git a/mydocs/report/edit_demo_4854/cursor-before-after.png b/mydocs/report/edit_demo_4854/cursor-before-after.png new file mode 100644 index 0000000000..8b7ab91088 Binary files /dev/null and b/mydocs/report/edit_demo_4854/cursor-before-after.png differ diff --git a/mydocs/report/task_m100_4854_report.md b/mydocs/report/task_m100_4854_report.md new file mode 100644 index 0000000000..d77cbad74f --- /dev/null +++ b/mydocs/report/task_m100_4854_report.md @@ -0,0 +1,121 @@ +# [#4854] 세션 도구 결과 이어보기 — 처리 결과 보고서 + +- 일자: 2026-08-15 +- 이슈: [#4854](https://github.com/edwardkim/rhwp/issues/4854) +- 기준: `upstream/devel` `627c8c49a` +- 변경 파일: `src/mcp_serve.rs`, `tests/mcp_result_cursor_contract.rs` + +## 1. 문제 + +#3787 S7 이 넣은 자원 상한(`maxMatches`·`maxChars`)은 컨텍스트 범람을 막지만 **이어보기와 +짝을 이루지 않는다**. 그래서 호출자는 둘 중 하나만 고를 수 있었다. + +1. 상한을 켠다 → 컨텍스트는 지키지만 뒤쪽 정보가 **영구 소실**된다. +2. 상한을 끈다(생략=무제한) → 정보는 다 받지만 컨텍스트가 범람한다. + +`hwp_doc_search` 가 특히 분명했다. 입력 스키마가 `docId`·`query`·`caseSensitive`·`maxMatches` +넷뿐이고 구현이 전수 grep 결과를 **앞에서부터** 잘라 냈다. + +```rust +// devel 627c8c49a · src/mcp_serve.rs:1702-1707 +let all = sd.doc.grep(query, case_sensitive, None); +let total = all.len(); +let shown: Vec<_> = match max_matches { + Some(n) => all.into_iter().take(n).collect(), + None => all, +}; +``` + +`take(n)` 은 항상 같은 앞 n 건이라 **n+1 번째 이후 매치는 이 도구로 도달할 수 없었다.** +봉투는 exit 0 · `truncated:true` 라 실패가 아니고, 잘린 뒤쪽에 정답이 있으면 작업은 조용히 +틀린 결론으로 끝난다. + +## 2. 실측 (전/후) + +같은 문서·같은 검색어·같은 창 크기로 **똑같은 요청 3건**을 두 바이너리에 던졌다. +BEFORE 는 `rhwp/target/debug/rhwp.exe`(그 체크아웃의 `src/mcp_serve.rs`·`src/main.rs` 는 +`git diff --stat upstream/devel...HEAD` 가 빈 결과 — devel 과 동일), AFTER 는 이 브랜치 빌드다. + +![전/후 비교](edit_demo_4854/cursor-before-after.png) + +``` +문서: samples/hwp3-sample.hwp · 검색어 "의" · 전체 매치 276건 · maxMatches=3 + +[BEFORE (devel 627c8c49a)] + 요청 offset=0 → 매치 ['0:16:25', '0:16:131', '0:18:50'] nextOffset=없음 + 요청 offset=3 → 매치 ['0:16:25', '0:16:131', '0:18:50'] nextOffset=없음 + 요청 offset=6 → 매치 ['0:16:25', '0:16:131', '0:18:50'] nextOffset=없음 + 판정: 창이 전진하지 않는다. 나머지 273건은 이 도구로 도달할 수단이 없다. + +[AFTER (#4854)] + 요청 offset=0 → 매치 ['0:16:25', '0:16:131', '0:18:50'] nextOffset=3 + 요청 offset=3 → 매치 ['0:18:91', '0:18:129', '0:18:152'] nextOffset=6 + 요청 offset=6 → 매치 ['0:18:223', '0:18:264', '0:18:415'] nextOffset=9 + 판정: 창이 매 홉 전진한다. 중복 0·누락 0, 전수 276건에 닿는다. +``` + +## 3. 변경 + +추가 전용(additive)이다. 인자를 생략하면 종전과 **바이트까지 같은 봉투**가 나간다. + +| 축 | 추가 | 의미 | +|---|---|---| +| `hwp_doc_search` | 입력 `offset` (0 이상, 기본 0) | 창의 시작 매치 번호 | +| `hwp_doc_text` | 입력 `charOffset` (0 이상, 기본 0) | 선택 쪽 범위를 이어 붙인 좌표의 시작 문자 | +| 두 봉투 | `nextOffset` | **남은 분량이 있을 때만** 실린다 | +| 두 봉투 | `offset`·`charOffset` 에코 | 인자가 0 이 아닐 때만 실린다 | + +설계에서 지킨 네 가지. + +1. **`nextOffset` 의 있음/없음이 유일한 종료 신호다.** 호출자가 총량 산술로 끝을 추론하지 + 않아도 된다. `truncated` 는 "이 응답이 전체가 아니다"라는 뜻이라 마지막 창에서도 true 일 + 수 있어 종료 판정에 쓸 수 없다 — 스키마 설명에 이 구별을 명시했다. +2. **총량은 창과 무관하게 고정이다.** `totalMatchCount` 가 오프셋에 따라 흔들리면 + "몇 건 중 몇 건"이라는 계약이 무너진다. +3. **오프셋의 `0` 은 유효값이다.** 상한의 `0`("아무것도 주지 마라")과 달리 오프셋의 `0` 은 + "처음부터"라 `opt_limit` 이 아니라 별도 `opt_offset` 을 뒀다. `-1`·`2.5`·`"3"` 은 거부한다 — + 오타를 "생략"으로 뭉개면 창이 조용히 처음으로 되돌아가 같은 구간을 무한히 다시 읽는다. +4. **총량을 넘긴 오프셋은 오류가 아니다.** 빈 결과 + `nextOffset` 없음으로 성공 처리한다. + 여기서 오류를 내면 성실한 호출자의 **마지막 한 번이 항상 실패**한다. +5. **쪽 주소를 보존한다.** 다 건너뛴 쪽도 `pages[]` 에서 빼지 않는다 — 빼면 `pageCount` 가 + 줄어 문서가 실제보다 짧아 보인다(#3787 S7 이 절단에서 지킨 규칙과 같은 이유). + +## 4. 검증 + +| 게이트 | 결과 | +|---|---| +| `cargo test --test mcp_result_cursor_contract` | **8 passed** (신규) | +| `cargo test --test boundary_integrity_contract` | 20 passed | +| `cargo test --test mcp_session_query_contract` | 6 passed | +| `cargo test --test mcp_server_contract` | 25 passed | +| `cargo test --test mcp_spec_ledger_contract` | 4 passed | +| `cargo test --test mcp_arg_validation_contract` | 9 passed | +| `cargo test --test mcp_tool_annotations_contract` | 5 passed | +| `cargo test --test mcp_next_call_contract` | 3 passed | +| `cargo clippy --all-targets -- -D warnings` | 통과 (exit 0) | +| `rustfmt --check` (변경 파일) | 통과 | + +신규 계약 8본이 못 박는 것. + +- `search_offset_reaches_matches_beyond_max_matches` — `maxMatches:1` 로 276홉을 돌아 **전수 + 도달**. 종전에는 존재할 수 없던 검사다. +- `search_window_partition_is_exact_for_larger_windows` — 창 2·3·7 에서 이어 붙인 결과가 + 전수와 **정확히** 일치(중복 0·누락 0·순서 보존). +- `omitting_offset_keeps_legacy_envelope_byte_identical` — 인자 생략 = `offset:0`, 봉투 원문 + 바이트 동일. +- `search_offset_past_total_is_success_not_error`, `text_char_offset_past_total_is_empty_success` + — 마지막 창을 넘긴 호출은 성공. +- `text_char_offset_resumes_and_preserves_page_addresses` — 본문 창을 이어 붙이면 전문과 동일, + `pageCount` 불변. +- `offset_arguments_are_declared_in_tool_schema` — 자기서술에 선언이 있고 `minimum` 이 0. +- `negative_and_malformed_offsets_are_rejected` — `-1`·`2.5`·`"3"` 거부. + +## 5. 알려진 비용과 비목표 + +- **비용**: `page` 를 생략한 `hwp_doc_text` 호출은 매번 전 쪽을 추출하므로, 창을 잘게 쪼갤수록 + 전체 훑기가 제곱으로 비싸진다. 이는 이 변경이 만든 비용이 아니라 종전부터 있던 호출 비용이 + 홉 수만큼 곱해지는 것이다. 쪽을 아는 경우 `page` 로 좁히면 비용은 그 쪽에만 든다. 계약 + 테스트도 이 성질 때문에 창을 800자로 잡았다(창 25자일 때 같은 검사가 125초, 800자에서 5초). +- **비목표**: 상한의 기본값을 바꾸지 않는다("생략=무제한"은 #3787 S7 의 의도된 계약이다). + 무상태 CLI 표면의 계약도 건드리지 않는다. 커서를 불투명 토큰으로 만들지 않는다 — 정수 + 오프셋이 결정론적이고 제3자가 손으로 검증할 수 있다. diff --git a/src/mcp_serve.rs b/src/mcp_serve.rs index 36e9d9a555..6a6d62a7c1 100644 --- a/src/mcp_serve.rs +++ b/src/mcp_serve.rs @@ -1128,7 +1128,8 @@ fn served_tools( "properties": { "docId": { "type": "string", "description": "hwp_open 이 돌려준 핸들" }, "page": { "type": "integer", "minimum": 0, "description": "0부터 시작하는 페이지 번호. 생략하면 전체" }, - "maxChars": { "type": "integer", "minimum": 1, "description": "[#3787 S7] 본문 전체의 문자 상한. 넘으면 truncated:true 와 omittedCount(생략 문자 수)를 봉투에 남긴다. 생략하면 무제한" } + "maxChars": { "type": "integer", "minimum": 1, "description": "[#3787 S7] 본문 전체의 문자 상한. 넘으면 truncated:true 와 omittedCount(생략 문자 수)를 봉투에 남긴다. 생략하면 무제한" }, + "charOffset": { "type": "integer", "minimum": 0, "description": "[#4854] 이어보기 시작 문자 위치 — 선택한 쪽 범위를 이어 붙인 좌표, 기본 0. 봉투의 nextOffset 을 그대로 다음 호출에 실으면 다음 창이고, nextOffset 이 없으면 더 없다. 총량을 넘긴 값은 오류가 아니라 빈 결과다" } }, "required": ["docId"] } @@ -1162,7 +1163,8 @@ fn served_tools( "docId": { "type": "string", "description": "hwp_open 이 돌려준 핸들" }, "query": { "type": "string", "minLength": 1, "description": "검색어" }, "caseSensitive": { "type": "boolean", "description": "대소문자 구분. 기본 true" }, - "maxMatches": { "type": "integer", "minimum": 1, "description": "[#3787 S7] 반환 매치 상한. 절단되면 totalMatchCount·truncated:true·omittedCount 가 총량을 알린다. 생략하면 무제한" } + "maxMatches": { "type": "integer", "minimum": 1, "description": "[#3787 S7] 반환 매치 상한. 절단되면 totalMatchCount·truncated:true·omittedCount 가 총량을 알린다. 생략하면 무제한" }, + "offset": { "type": "integer", "minimum": 0, "description": "[#4854] 이어보기 시작 매치 번호(0부터, 기본 0). 봉투의 nextOffset 을 그대로 다음 호출에 실으면 다음 창이고, nextOffset 이 없으면 더 없다 — truncated 는 '이 응답이 전체가 아니다'라는 뜻이라 마지막 창에서도 true 일 수 있으니 '더 있는가'의 판정은 nextOffset 으로 한다" } }, "required": ["docId", "query"] } @@ -1497,6 +1499,17 @@ fn opt_limit(args: &serde_json::Value, key: &str) -> Result, Strin } } +/// [#4854] 이어보기 시작점(0 이상). [`opt_limit`] 과 달리 `0` 을 거부하지 **않는다** — +/// 상한에서의 `0` 은 "아무것도 주지 마라"라 무제한과 뭉개면 정반대로 실행되지만, +/// 오프셋의 `0` 은 "처음부터"라는 기본값 그 자체다. 생략도 `0` 과 같은 뜻이라 인자를 +/// 안 보내면 종전 경로와 바이트까지 같은 봉투가 나간다. +fn opt_offset(args: &serde_json::Value, key: &str) -> Result { + match opt_u64(args, key)? { + None => Ok(0), + Some(n) => usize::try_from(n).map_err(|_| format!("{key} 범위 초과: {n}")), + } +} + /// 필수 정수. "생략"과 "형식 오류"를 서로 다른 문구로 보고한다 — 같은 문구로 뭉개면 /// 호출자가 값이 아니라 호출 형태를 의심하며 헛수고한다. fn req_u64(args: &serde_json::Value, key: &str) -> Result { @@ -1531,6 +1544,11 @@ fn session_doc_text(args: &serde_json::Value, sessions: &mut Sessions) -> serde_ Ok(v) => v, Err(e) => return tool_error(e), }; + // [#4854] 상한만 있고 이어보기가 없으면 상한을 켤수록 문서 뒤쪽이 영구히 사라진다. + let char_offset = match opt_offset(args, "charOffset") { + Ok(v) => v, + Err(e) => return tool_error(e), + }; let pages: Vec = match page_arg { Some(raw_page) => { let p = match u32::try_from(raw_page) { @@ -1551,20 +1569,55 @@ fn session_doc_text(args: &serde_json::Value, sessions: &mut Sessions) -> serde_ Err(e) => return tool_error(format!("페이지 {p} 텍스트 추출 실패: {e:?}")), } } + // [#4854] 선택한 쪽 범위를 이어 붙인 좌표에서 char_offset 만큼 건너뛴다. 다 건너뛴 + // 쪽도 목록에서 **빼지 않는다** — 빼면 pageCount 가 줄어 문서가 실제보다 짧아 보인다 + // (#3787 S7 이 절단에서 지킨 규칙과 같은 이유다). + let total_chars: usize = extracted.iter().map(|(_, t)| t.chars().count()).sum(); + let mut skip = char_offset; + let windowed: Vec<(u32, String)> = extracted + .into_iter() + .map(|(p, text)| { + if skip == 0 { + return (p, text); + } + let len = text.chars().count(); + if skip >= len { + skip -= len; + (p, String::new()) + } else { + let tail = text.chars().skip(skip).collect(); + skip = 0; + (p, tail) + } + }) + .collect(); // [#3787 S7] 무상태 `export-text --json --max-chars` 와 같은 helper 를 쓴다 — // 절단 어휘(truncated·omittedCount)가 두 표면에서 갈라지지 않게 한다. - let (page_objs, omitted_count) = crate::truncate_page_texts(&extracted, max_chars); - tool_ok_text( - serde_json::json!({ - "schemaVersion": ENVELOPE_SCHEMA_VERSION, - "docId": doc_id, - "pageCount": page_objs.len(), - "truncated": omitted_count > 0, - "omittedCount": omitted_count, - "pages": page_objs, - }) - .to_string(), - ) + let (page_objs, omitted_count) = crate::truncate_page_texts(&windowed, max_chars); + let shown_chars: usize = page_objs + .iter() + .filter_map(|o| o["text"].as_str()) + .map(|t| t.chars().count()) + .sum(); + let mut envelope = serde_json::json!({ + "schemaVersion": ENVELOPE_SCHEMA_VERSION, + "docId": doc_id, + "pageCount": page_objs.len(), + "truncated": omitted_count > 0, + "omittedCount": omitted_count, + "pages": page_objs, + }); + // [#4854] 남은 분량이 있을 때만 싣는다 — 필드의 있음/없음 자체가 "더 있다"의 신호라 + // 호출자가 총량 산술로 끝을 추론하지 않아도 된다. char_offset 이 총량을 넘으면 + // 빈 결과 + nextOffset 없음이고, 그건 오류가 아니라 "더 없음"이다. + let consumed = char_offset.saturating_add(shown_chars); + if consumed < total_chars { + envelope["nextOffset"] = serde_json::json!(consumed); + } + if char_offset > 0 { + envelope["charOffset"] = serde_json::json!(char_offset); + } + tool_ok_text(envelope.to_string()) } /// [#3609] 세션 조회 4종 — 전부 무상태 봉투 helper 재사용(동형 보장). @@ -1689,6 +1742,11 @@ fn session_search(args: &serde_json::Value, sessions: &mut Sessions) -> serde_js Ok(v) => v, Err(e) => return tool_error(e), }; + // [#4854] `take(n)` 만 있으면 n+1 번째 이후 매치는 이 도구로 도달할 방법이 없다. + let offset = match opt_offset(args, "offset") { + Ok(v) => v, + Err(e) => return tool_error(e), + }; let Some(sd) = sessions.docs.get_mut(doc_id) else { return tool_error_with_next( format!("열려 있지 않은 핸들: {doc_id} (hwp_open 먼저)"), @@ -1701,11 +1759,24 @@ fn session_search(args: &serde_json::Value, sessions: &mut Sessions) -> serde_js // --max-matches` 와 같은 규칙이라 totalMatchCount 가 두 표면에서 같은 뜻이다. let all = sd.doc.grep(query, case_sensitive, None); let total = all.len(); + // [#4854] 총량은 그대로 두고 **창(window)만** 옮긴다 — totalMatchCount 의 뜻이 + // 오프셋에 따라 흔들리면 "몇 건 중 몇 건"이라는 계약이 무너진다. + let skipped = all.into_iter().skip(offset); let shown: Vec<_> = match max_matches { - Some(n) => all.into_iter().take(n).collect(), - None => all, - }; - tool_ok_text(crate::search_json_value(doc_id, query, case_sensitive, &shown, total).to_string()) + Some(n) => skipped.take(n).collect(), + None => skipped.collect(), + }; + let mut envelope = crate::search_json_value(doc_id, query, case_sensitive, &shown, total); + // [#4854] 마지막 창에서도 truncated 는 true 다(이 응답 != 전체). "더 있는가"의 + // 유일한 판정은 nextOffset 의 있음/없음이다. + let consumed = offset.saturating_add(shown.len()); + if consumed < total { + envelope["nextOffset"] = serde_json::json!(consumed); + } + if offset > 0 { + envelope["offset"] = serde_json::json!(offset); + } + tool_ok_text(envelope.to_string()) } /// [#3719 §6-1] 세션 편집 봉투의 `changedPages` — 무상태 판(#3712)과 **같은** 코어 diff --git a/tests/mcp_result_cursor_contract.rs b/tests/mcp_result_cursor_contract.rs new file mode 100644 index 0000000000..8c88fe2901 --- /dev/null +++ b/tests/mcp_result_cursor_contract.rs @@ -0,0 +1,506 @@ +//! [#4854] 세션 도구 결과의 **이어보기** 계약 — 절단이 손실로 끝나지 않는다. +//! +//! #3787 S7 이 넣은 자원 상한(`maxMatches`·`maxChars`)은 컨텍스트 범람을 막지만, +//! 상한만 있고 이어보기가 없으면 호출자는 "컨텍스트를 지키고 뒤쪽을 잃거나" +//! "전부 받고 범람하거나" 둘 중 하나만 고를 수 있었다. `hwp_doc_search` 는 특히 +//! `take(n)` 이라 n+1 번째 이후 매치에 **도달할 인자 자체가 없었다**. +//! +//! 이 파일이 못 박는 것은 네 가지다. +//! +//! 1. 창을 옮겨 가며 부르면 전수에 도달한다 — 상한을 켠 채로. +//! 2. 창들을 이어 붙이면 원본과 **정확히** 같다(중복 0·누락 0·순서 보존). +//! 3. "더 있는가"의 판정은 `nextOffset` 의 있음/없음 하나다. +//! 4. 인자를 생략하면 종전 봉투와 바이트까지 같다. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +/// 본문에 조사 "의" 가 다수 나오는 HWP3 표본 — 창 넘기기를 여러 번 돌릴 표적. +const SAMPLE: &str = "samples/hwp3-sample.hwp"; +/// 검색어. 표본에서 매치가 충분히 많아야 창 넘기기가 의미를 가진다. +const QUERY: &str = "의"; + +fn sample() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(SAMPLE) +} + +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Server { + fn started() -> Server { + let mut child = Command::new(env!("CARGO_BIN_EXE_rhwp")) + .arg("mcp-serve") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("rhwp mcp-serve 실행 실패"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + let mut s = Server { + child, + stdin, + stdout, + next_id: 1, + }; + let r = s.request( + "initialize", + serde_json::json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "result-cursor-test", "version": "0"} + }), + ); + assert!(r["result"]["serverInfo"]["name"].is_string(), "{r}"); + s + } + + fn request(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value { + let id = self.next_id; + self.next_id += 1; + let msg = + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(self.stdin, "{msg}").expect("요청 쓰기 실패"); + self.stdin.flush().expect("flush"); + let mut line = String::new(); + loop { + line.clear(); + let n = self.stdout.read_line(&mut line).expect("응답 읽기 실패"); + assert!(n > 0, "서버가 응답 없이 종료했습니다 (method={method})"); + if line.trim().is_empty() { + continue; + } + let v: serde_json::Value = serde_json::from_str(line.trim()) + .unwrap_or_else(|e| panic!("stdout 이 JSON-RPC 가 아닙니다 ({e}): {line}")); + if v.get("id").and_then(|i| i.as_i64()) == Some(id) { + return v; + } + } + } + + /// 도구 호출의 원문(text)까지 돌려준다 — "바이트까지 같다"를 검사하려면 + /// 파싱된 값이 아니라 직렬화 원문을 비교해야 한다. + fn call_raw(&mut self, name: &str, args: serde_json::Value) -> (bool, String) { + let r = self.request( + "tools/call", + serde_json::json!({"name": name, "arguments": args}), + ); + let result = &r["result"]; + let is_error = result["isError"].as_bool().unwrap_or(false); + let text = result["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); + (is_error, text) + } + + fn call(&mut self, name: &str, args: serde_json::Value) -> (bool, serde_json::Value) { + let (is_error, text) = self.call_raw(name, args); + let v = serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)); + (is_error, v) + } + + fn open(&mut self, path: &Path) -> String { + let (err, v) = self.call( + "hwp_open", + serde_json::json!({"path": path.to_str().unwrap()}), + ); + assert!(!err, "hwp_open 실패: {v}"); + v["docId"].as_str().expect("docId").to_string() + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// 매치의 신원 — 창을 이어 붙였을 때 중복·누락·순서를 판정할 좌표. +fn match_key(m: &serde_json::Value) -> String { + format!( + "{}:{}:{}:{}", + m["section"], m["paragraph"], m["charOffset"], m["length"] + ) +} + +#[test] +fn search_offset_reaches_matches_beyond_max_matches() { + // 이 계약의 핵심. maxMatches 를 켠 채 창을 넘기면 **마지막 매치까지** 닿는다. + // 종전(take(n) 전용)에는 n+1 번째 이후에 도달할 인자가 없었다. + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + + let (err, full) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY}), + ); + assert!(!err, "{full}"); + let total = full["totalMatchCount"].as_u64().expect("totalMatchCount") as usize; + assert!( + total >= 3, + "전제: 창 넘기기를 검사하려면 매치가 3건 이상이어야 합니다 (total={total})" + ); + let expected: Vec = full["matches"] + .as_array() + .expect("matches") + .iter() + .map(match_key) + .collect(); + + // 한 번에 1건씩만 받는 가장 인색한 창으로 전수를 훑는다. + let mut seen: Vec = Vec::new(); + let mut offset = 0u64; + let mut hops = 0; + loop { + let (err, v) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY, "maxMatches": 1, "offset": offset}), + ); + assert!(!err, "offset={offset} 에서 실패: {v}"); + assert_eq!( + v["totalMatchCount"].as_u64(), + Some(total as u64), + "totalMatchCount 는 창과 무관하게 고정이어야 합니다: {v}" + ); + for m in v["matches"].as_array().expect("matches") { + seen.push(match_key(m)); + } + hops += 1; + assert!(hops <= total + 2, "창 넘기기가 끝나지 않습니다 (무한 루프)"); + match v.get("nextOffset").and_then(|n| n.as_u64()) { + Some(next) => { + assert!(next > offset, "nextOffset 이 전진하지 않습니다: {v}"); + offset = next; + } + // nextOffset 없음 = 더 없음. 이 신호 하나로 종료를 판정한다. + None => break, + } + } + + assert_eq!( + seen, expected, + "창을 이어 붙인 결과가 전수와 다릅니다 (중복·누락·순서)" + ); + assert_eq!(seen.len(), total, "전수 {total} 건에 도달하지 못했습니다"); +} + +#[test] +fn search_window_partition_is_exact_for_larger_windows() { + // 창 크기가 1 이 아닐 때도 분할이 정확한가 — 경계에서 1건이 겹치거나 새면 + // 에이전트는 같은 자리를 두 번 고치거나 한 자리를 놓친다. + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + let (_, full) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY}), + ); + let total = full["totalMatchCount"].as_u64().expect("totalMatchCount") as usize; + let expected: Vec = full["matches"] + .as_array() + .expect("matches") + .iter() + .map(match_key) + .collect(); + + for window in [2usize, 3, 7] { + let mut seen: Vec = Vec::new(); + let mut offset = 0u64; + loop { + let (err, v) = s.call( + "hwp_doc_search", + serde_json::json!({ + "docId": doc_id, "query": QUERY, "maxMatches": window, "offset": offset + }), + ); + assert!(!err, "{v}"); + let got = v["matches"].as_array().expect("matches"); + assert!(got.len() <= window, "창 크기를 넘겨 반환했습니다: {v}"); + for m in got { + seen.push(match_key(m)); + } + match v.get("nextOffset").and_then(|n| n.as_u64()) { + Some(next) => offset = next, + None => break, + } + } + assert_eq!(seen, expected, "창={window} 에서 분할이 어긋났습니다"); + assert_eq!(seen.len(), total, "창={window} 에서 전수 미도달"); + } +} + +#[test] +fn search_offset_past_total_is_success_not_error() { + // 마지막 창을 넘겨 부르는 일은 정상 루프에서 일어난다. 여기서 오류를 내면 + // 성실한 호출자의 마지막 한 번이 **항상** 실패한다. + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + let (_, full) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY}), + ); + let total = full["totalMatchCount"].as_u64().expect("totalMatchCount"); + + let (err, v) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY, "offset": total + 10}), + ); + assert!( + !err, + "총량 초과 오프셋은 오류가 아니라 '더 없음'입니다: {v}" + ); + assert_eq!(v["matches"].as_array().map(Vec::len), Some(0), "{v}"); + assert!( + v.get("nextOffset").is_none(), + "더 없는데 nextOffset 이 붙었습니다: {v}" + ); + assert_eq!( + v["totalMatchCount"].as_u64(), + Some(total), + "총량은 창과 무관해야 합니다: {v}" + ); +} + +#[test] +fn omitting_offset_keeps_legacy_envelope_byte_identical() { + // 이어보기는 **추가 전용**이다. 인자를 안 보내면 종전과 같은 바이트여야 + // 기존 호출자의 스냅샷·해시가 깨지지 않는다. + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + + let (_, omitted) = s.call_raw( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY}), + ); + let (_, explicit_zero) = s.call_raw( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY, "offset": 0}), + ); + assert_eq!( + omitted, explicit_zero, + "offset 생략과 offset:0 은 같은 봉투여야 합니다" + ); + let parsed: serde_json::Value = serde_json::from_str(&omitted).expect("봉투 JSON"); + assert!( + parsed.get("nextOffset").is_none(), + "상한이 없어 전수를 실었는데 nextOffset 이 붙었습니다: {parsed}" + ); + assert!( + parsed.get("offset").is_none(), + "기본 창에는 offset 을 싣지 않습니다: {parsed}" + ); + + let (_, text_omitted) = s.call_raw("hwp_doc_text", serde_json::json!({"docId": doc_id})); + let (_, text_zero) = s.call_raw( + "hwp_doc_text", + serde_json::json!({"docId": doc_id, "charOffset": 0}), + ); + assert_eq!( + text_omitted, text_zero, + "charOffset 생략과 0 은 같은 봉투여야 합니다" + ); +} + +#[test] +fn text_char_offset_resumes_and_preserves_page_addresses() { + // 본문 축. 창을 이어 붙이면 전문과 같아야 하고, 다 건너뛴 쪽이라도 pages[] + // 에서 빠지면 안 된다 — 빠지면 pageCount 가 줄어 문서가 짧아 보인다. + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + + let (err, full) = s.call("hwp_doc_text", serde_json::json!({"docId": doc_id})); + assert!(!err, "{full}"); + let page_count = full["pageCount"].as_u64().expect("pageCount"); + let whole: String = full["pages"] + .as_array() + .expect("pages") + .iter() + .filter_map(|p| p["text"].as_str()) + .collect(); + assert!( + whole.chars().count() > 40, + "전제: 창 넘기기를 검사할 만큼 본문이 있어야 합니다" + ); + + // 창 크기는 계약이 아니라 **비용**의 문제다. `page` 를 생략한 호출은 매번 전 쪽을 + // 추출하므로 창이 작을수록 홉 수가 늘어 전체 훑기가 제곱으로 비싸진다 — 계약을 + // 증명할 만큼만 작게 잡는다(여러 홉 + 마지막 홉의 부분 창). + let window = 800usize; + let mut assembled = String::new(); + let mut offset = 0u64; + let mut hops = 0; + loop { + let (err, v) = s.call( + "hwp_doc_text", + serde_json::json!({"docId": doc_id, "maxChars": window, "charOffset": offset}), + ); + assert!(!err, "charOffset={offset} 에서 실패: {v}"); + assert_eq!( + v["pageCount"].as_u64(), + Some(page_count), + "창을 옮겨도 쪽 주소는 보존해야 합니다: {v}" + ); + for p in v["pages"].as_array().expect("pages") { + assembled.push_str(p["text"].as_str().unwrap_or("")); + } + hops += 1; + assert!( + hops <= whole.chars().count() / window + 4, + "창 넘기기가 끝나지 않습니다 (무한 루프)" + ); + match v.get("nextOffset").and_then(|n| n.as_u64()) { + Some(next) => { + assert!(next > offset, "nextOffset 이 전진하지 않습니다: {v}"); + offset = next; + } + None => break, + } + } + assert_eq!( + assembled, whole, + "본문 창을 이어 붙인 결과가 전문과 다릅니다" + ); +} + +#[test] +fn text_char_offset_past_total_is_empty_success() { + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + let (_, full) = s.call("hwp_doc_text", serde_json::json!({"docId": doc_id})); + let total: usize = full["pages"] + .as_array() + .expect("pages") + .iter() + .filter_map(|p| p["text"].as_str()) + .map(|t| t.chars().count()) + .sum(); + + let (err, v) = s.call( + "hwp_doc_text", + serde_json::json!({"docId": doc_id, "charOffset": total + 100}), + ); + assert!(!err, "총량 초과 charOffset 은 오류가 아닙니다: {v}"); + assert!( + v.get("nextOffset").is_none(), + "더 없는데 nextOffset 이 붙었습니다: {v}" + ); + let left: usize = v["pages"] + .as_array() + .expect("pages") + .iter() + .filter_map(|p| p["text"].as_str()) + .map(|t| t.chars().count()) + .sum(); + assert_eq!(left, 0, "총량을 넘겼으면 남은 본문이 없어야 합니다: {v}"); + assert_eq!( + v["pageCount"].as_u64(), + full["pageCount"].as_u64(), + "쪽 주소는 여기서도 보존한다: {v}" + ); +} + +#[test] +fn offset_arguments_are_declared_in_tool_schema() { + // 자기서술이 없으면 호출자는 이 인자의 존재를 알 수 없다 — 선언이 계약의 절반. + let mut s = Server::started(); + let r = s.request("tools/list", serde_json::json!({})); + let tools = r["result"]["tools"].as_array().expect("tools"); + let find = |name: &str| { + tools + .iter() + .find(|t| t["name"] == name) + .unwrap_or_else(|| panic!("{name} 도구가 없습니다")) + .clone() + }; + + let search = find("hwp_doc_search"); + assert!( + search["inputSchema"]["properties"]["offset"].is_object(), + "hwp_doc_search 에 offset 선언이 없습니다: {search}" + ); + assert_eq!( + search["inputSchema"]["properties"]["offset"]["minimum"].as_u64(), + Some(0), + "오프셋의 하한은 0 이다 (상한과 달리 0 이 유효값): {search}" + ); + + let text = find("hwp_doc_text"); + assert!( + text["inputSchema"]["properties"]["charOffset"].is_object(), + "hwp_doc_text 에 charOffset 선언이 없습니다: {text}" + ); + assert_eq!( + text["inputSchema"]["properties"]["charOffset"]["minimum"].as_u64(), + Some(0), + "{text}" + ); +} + +#[test] +fn negative_and_malformed_offsets_are_rejected() { + // 오프셋 오타를 "생략"으로 뭉개면 창이 조용히 처음으로 되돌아가 같은 구간을 + // 무한히 다시 읽는다. 거부가 유일하게 안전한 처리다(#3884 의 교훈과 같다). + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + + for bad in [ + serde_json::json!(-1), + serde_json::json!(2.5), + serde_json::json!("3"), + ] { + let (err, v) = s.call( + "hwp_doc_search", + serde_json::json!({"docId": doc_id, "query": QUERY, "offset": bad}), + ); + assert!(err, "잘못된 offset({bad})을 받아들였습니다: {v}"); + + let (err, v) = s.call( + "hwp_doc_text", + serde_json::json!({"docId": doc_id, "charOffset": bad}), + ); + assert!(err, "잘못된 charOffset({bad})을 받아들였습니다: {v}"); + } +}