@things-factory/board-ai 10.1.27 → 10.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/board-session-resolver.js +20 -2
- package/dist-server/service/board-session-resolver.js.map +1 -1
- package/dist-server/service/mention-marker.d.ts +1 -11
- package/dist-server/service/mention-marker.js +10 -41
- package/dist-server/service/mention-marker.js.map +1 -1
- package/dist-server/service/patch-entry/revert-permission.d.ts +5 -0
- package/dist-server/service/patch-entry/revert-permission.js +9 -0
- package/dist-server/service/patch-entry/revert-permission.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/board-session-resolver.ts +24 -2
- package/server/service/mention-marker.ts +7 -39
- package/server/service/patch-entry/revert-permission.ts +28 -0
- package/test/revert-permission.test.ts +41 -0
- package/test/fold-history-window.test.ts +0 -158
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-ai",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.29",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"@operato/i18n": "^10.0.0",
|
|
33
33
|
"@operato/scene-ops": "^10.14.1",
|
|
34
34
|
"@operato/styles": "^10.0.0",
|
|
35
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
36
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
37
|
-
"@things-factory/auth-base": "^10.1.
|
|
38
|
-
"@things-factory/board-import": "^10.1.
|
|
35
|
+
"@things-factory/ai-assistant": "^10.1.29",
|
|
36
|
+
"@things-factory/ai-client-base": "^10.1.29",
|
|
37
|
+
"@things-factory/auth-base": "^10.1.29",
|
|
38
|
+
"@things-factory/board-import": "^10.1.29",
|
|
39
39
|
"@things-factory/env": "^10.1.20",
|
|
40
|
-
"@things-factory/shell": "^10.1.
|
|
40
|
+
"@things-factory/shell": "^10.1.29",
|
|
41
41
|
"dompurify": "^3.0.0",
|
|
42
42
|
"graphql-tag": "^2.12.6",
|
|
43
43
|
"lit": "^3.1.2",
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"copyfiles": "^2.4.1",
|
|
48
48
|
"rimraf": "^5.0.0"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
|
|
51
51
|
}
|
|
@@ -30,7 +30,10 @@ import { publishSessionActivity } from '@things-factory/ai-assistant'
|
|
|
30
30
|
import { ChatMessage } from '@things-factory/ai-assistant'
|
|
31
31
|
import { publishChatMessage } from '@things-factory/ai-assistant'
|
|
32
32
|
import { ChatSessionResolver } from '@things-factory/ai-assistant'
|
|
33
|
+
import { ChatSessionParticipant } from '@things-factory/ai-assistant'
|
|
34
|
+
import { mayReadSession } from '@things-factory/ai-assistant'
|
|
33
35
|
import { PatchEntry } from './patch-entry/patch-entry.js'
|
|
36
|
+
import { mayRevertPatch } from './patch-entry/revert-permission.js'
|
|
34
37
|
import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
|
|
35
38
|
|
|
36
39
|
/** 중립 op 로의 위임 대상. 리졸버는 상태가 없으므로 하나면 된다. */
|
|
@@ -176,8 +179,27 @@ export class BoardSessionResolver {
|
|
|
176
179
|
@Arg('patchId') patchId: string,
|
|
177
180
|
@Ctx() context: ResolverContext
|
|
178
181
|
): Promise<boolean> {
|
|
179
|
-
const { tx } = context.state
|
|
180
|
-
|
|
182
|
+
const { domain, user, tx } = context.state
|
|
183
|
+
|
|
184
|
+
/* A patch id alone is not permission — see `patch-entry/revert-permission.ts`. */
|
|
185
|
+
const patch = await getRepository(PatchEntry, tx).findOne({
|
|
186
|
+
where: { id: patchId, session: { domain: { id: domain.id } } } as any,
|
|
187
|
+
relations: ['session']
|
|
188
|
+
})
|
|
189
|
+
const session: any = patch?.session
|
|
190
|
+
const isParticipant =
|
|
191
|
+
!!session && !!user?.id
|
|
192
|
+
? !!(await getRepository(ChatSessionParticipant, tx).findOneBy({
|
|
193
|
+
session: { id: session.id },
|
|
194
|
+
user: { id: user.id }
|
|
195
|
+
} as any))
|
|
196
|
+
: false
|
|
197
|
+
|
|
198
|
+
if (!mayRevertPatch(patch ? { sessionVisibility: session?.visibility } : null, isParticipant, mayReadSession)) {
|
|
199
|
+
throw new Error(`PatchEntry ${patchId} not found`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await getRepository(PatchEntry, tx).update(patch!.id!, { reverted: true })
|
|
181
203
|
return true
|
|
182
204
|
}
|
|
183
205
|
}
|
|
@@ -27,47 +27,15 @@ import { findMentions, type MentionPick } from './mention-resolver'
|
|
|
27
27
|
*/
|
|
28
28
|
export const MENTION_MARKER_RE = /\{[a-zA-Z][a-zA-Z0-9]*:[^}\s]+\}/g
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
/*
|
|
31
|
+
* 이 둘은 **`@things-factory/ai-assistant` 로 옮겼다**(ADR-0071 결정 1).
|
|
32
|
+
*
|
|
33
|
+
* 멘션이 사람을 불러들이는 일은 보드만의 일이 아니다 — 트윈 협의에서 `@이름` 은 아무도 부르지 않고
|
|
34
|
+
* 있었다. 규칙이 이 제품 안에 있는 한 다른 문은 그것을 쓸 수 없다. 여기서는 이름만 다시 내보내
|
|
35
|
+
* 이 패키지의 부르는 자리가 그대로 돌게 한다.
|
|
33
36
|
*/
|
|
34
|
-
export
|
|
35
|
-
mentioned: ReadonlyArray<string>,
|
|
36
|
-
existing: ReadonlyArray<string>,
|
|
37
|
-
selfUserId?: string | null
|
|
38
|
-
): string[] {
|
|
39
|
-
const have = new Set(existing)
|
|
40
|
-
if (selfUserId) have.add(selfUserId)
|
|
41
|
-
const out: string[] = []
|
|
42
|
-
const seen = new Set<string>()
|
|
43
|
-
for (const id of mentioned) {
|
|
44
|
-
if (!id || have.has(id) || seen.has(id)) continue
|
|
45
|
-
seen.add(id)
|
|
46
|
-
out.push(id)
|
|
47
|
-
}
|
|
48
|
-
return out
|
|
49
|
-
}
|
|
37
|
+
export { extractMentionUserIds, newParticipantUserIds } from '@things-factory/ai-assistant'
|
|
50
38
|
|
|
51
|
-
/**
|
|
52
|
-
* 본문에서 `@user{userId:UUID}` 마커의 *userId 집합* 추출 (C4 — 초대 트리거).
|
|
53
|
-
* 같은 user 중복 제거, 순서 보존(첫 등장순). 마커 없으면 빈 배열.
|
|
54
|
-
* 순수 — content 만으로 "이 메시지가 누구를 멘션했나" 판정.
|
|
55
|
-
*/
|
|
56
|
-
export function extractMentionUserIds(text: string | null | undefined): string[] {
|
|
57
|
-
if (!text) return []
|
|
58
|
-
const re = /\{userId:([^}\s]+)\}/g
|
|
59
|
-
const seen = new Set<string>()
|
|
60
|
-
const out: string[] = []
|
|
61
|
-
let m: RegExpExecArray | null
|
|
62
|
-
while ((m = re.exec(text)) !== null) {
|
|
63
|
-
const id = m[1]
|
|
64
|
-
if (!seen.has(id)) {
|
|
65
|
-
seen.add(id)
|
|
66
|
-
out.push(id)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return out
|
|
70
|
-
}
|
|
71
39
|
|
|
72
40
|
/**
|
|
73
41
|
* 본문에서 모든 `{key:value}` 인라인 마커 제거. LLM 입력 / textarea 편집 / 평문 표출 등.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Who may flag a patch as reverted.
|
|
3
|
+
*
|
|
4
|
+
* `revertPatch` used to take a patch id and write — the privilege gate was the only check, so anyone
|
|
5
|
+
* holding `board-ai:mutation` in ANY tenant could flag a patch in another tenant's conversation by
|
|
6
|
+
* knowing (or guessing) its id, and a person could reach into a private conversation they are not
|
|
7
|
+
* part of (operato-twin ADR-0061 "what was left", found again by the 2026-09-18 ADR audit).
|
|
8
|
+
*
|
|
9
|
+
* The rule is the conversation's own: a patch belongs to a session, and a person may act on it only
|
|
10
|
+
* where they may read that session — the caller's tenant, and for a private session, a participant
|
|
11
|
+
* (ADR-0073 decision 2). The lookup is scoped by tenant, so a patch elsewhere simply is not found.
|
|
12
|
+
*
|
|
13
|
+
* One answer for every refusal. Telling "exists but not yours" apart from "does not exist" would
|
|
14
|
+
* confirm an id to someone who should not learn it.
|
|
15
|
+
*/
|
|
16
|
+
export interface FoundPatch {
|
|
17
|
+
/** `ChatSession.visibility` of the session the patch belongs to. */
|
|
18
|
+
sessionVisibility?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function mayRevertPatch(
|
|
22
|
+
found: FoundPatch | null | undefined,
|
|
23
|
+
isParticipant: boolean,
|
|
24
|
+
mayReadSession: (visibility: string | null | undefined, isParticipant: boolean) => boolean
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!found) return false
|
|
27
|
+
return mayReadSession(found.sessionVisibility, isParticipant)
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* revertPatch — a patch id alone is not permission.
|
|
3
|
+
*
|
|
4
|
+
* The resolver cannot be imported here (type-graphql decorators), so the judgement lives in a pure
|
|
5
|
+
* function and the resolver only feeds it. What this file pins is the rule; that the resolver asks
|
|
6
|
+
* it, and asks with a tenant-scoped lookup, is pinned by reading its source below.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { mayRevertPatch } from '../server/service/patch-entry/revert-permission.ts'
|
|
14
|
+
import { mayReadSession } from '../../ai-assistant/server/service/chat-session/session-visibility.ts'
|
|
15
|
+
|
|
16
|
+
test('a patch that the tenant-scoped lookup did not find is refused', () => {
|
|
17
|
+
assert.equal(mayRevertPatch(null, true, mayReadSession), false)
|
|
18
|
+
assert.equal(mayRevertPatch(undefined, false, mayReadSession), false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('an open conversation lets anyone in the tenant flag its patch', () => {
|
|
22
|
+
assert.equal(mayRevertPatch({ sessionVisibility: 'open' }, false, mayReadSession), true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('a private conversation lets only a participant', () => {
|
|
26
|
+
assert.equal(mayRevertPatch({ sessionVisibility: 'private' }, false, mayReadSession), false)
|
|
27
|
+
assert.equal(mayRevertPatch({ sessionVisibility: 'private' }, true, mayReadSession), true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('a session written before the column existed reads as open, the same as everywhere else', () => {
|
|
31
|
+
assert.equal(mayRevertPatch({ sessionVisibility: null }, false, mayReadSession), true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const RESOLVER = readFileSync(join(import.meta.dirname, '../server/service/board-session-resolver.ts'), 'utf-8')
|
|
35
|
+
const body = RESOLVER.slice(RESOLVER.indexOf('async revertPatch('))
|
|
36
|
+
|
|
37
|
+
test('the door looks the patch up inside the caller\'s tenant, and asks the rule before writing', () => {
|
|
38
|
+
assert.ok(/domain:\s*\{\s*id:\s*domain\.id\s*\}/.test(body), 'the lookup is not scoped by domain')
|
|
39
|
+
assert.ok(body.indexOf('mayRevertPatch(') > -1, 'the door does not ask the rule')
|
|
40
|
+
assert.ok(body.indexOf('mayRevertPatch(') < body.indexOf('.update('), 'the door writes before it asks')
|
|
41
|
+
})
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* ★ 요약 접기는 **필요한 만큼만 읽는다** — 그러면서 앞부분을 잃지 않는다.
|
|
3
|
-
*
|
|
4
|
-
* ── 무엇을 겪었나 (2026-08-20) ──────────────────────────────────────────────
|
|
5
|
-
* `_foldHistorySummary` 는 **메시지를 보낼 때마다** 그 세션의 대화를 처음부터 끝까지 읽었다
|
|
6
|
-
* (`creator` 조인까지). 정작 쓰는 것은 두 조각뿐이다: 마지막 `maxTurns`개와, 요약 워터마크
|
|
7
|
-
* 뒤부터의 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
|
|
8
|
-
*
|
|
9
|
-
* 같은 종류의 결함을 트윈 쪽에서 먼저 잡았다(`timeRange` — min/max 두 수를 구하려고 저널 27만
|
|
10
|
-
* 행을 4초마다 읽었다). 사람 리뷰가 그것을 못 잡은 이유는 **코드가 맞았기 때문**이다. 여기도 같다:
|
|
11
|
-
* 답은 맞고 비용만 틀렸다. 그래서 이 시험은 답과 함께 **어디까지 읽어야 하는가**를 못 박는다.
|
|
12
|
-
*
|
|
13
|
-
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
14
|
-
* ① 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다.
|
|
15
|
-
* ② 전체를 읽어 왔으면 워터마크로 자른다 — 이미 요약에 담은 것을 두 번 담지 않는다.
|
|
16
|
-
* ③ 두 경로가 **같은 답**을 낸다. 읽는 범위를 바꾼 것이 요약 내용을 바꾸면 안 된다.
|
|
17
|
-
* ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
|
|
18
|
-
*/
|
|
19
|
-
import { test } from 'node:test'
|
|
20
|
-
import assert from 'node:assert/strict'
|
|
21
|
-
import { createRequire } from 'node:module'
|
|
22
|
-
|
|
23
|
-
/*
|
|
24
|
-
* 컴파일 산출물에서 불러온다 — 소스는 `./history-summary.js` 형태로 상대 임포트를 하고, 시험 로더는
|
|
25
|
-
* 그 `.js` 를 `.ts` 로 되돌려 주지 않는다(이 저장소의 다른 엔진 시험도 같은 이유로 dist 를 쓴다).
|
|
26
|
-
*/
|
|
27
|
-
const req = createRequire(import.meta.url)
|
|
28
|
-
const { foldHistorySummary } = req('../dist-server/service/chat-message/fold-history.js')
|
|
29
|
-
const { splitByCap, pendingForSummary } = req('../dist-server/service/chat-message/history-summary.js')
|
|
30
|
-
|
|
31
|
-
const MAX_TURNS = 5
|
|
32
|
-
const THRESHOLD = 3
|
|
33
|
-
|
|
34
|
-
/** 메시지 n개 — id 는 `m1`.. 로 두어 어디까지 담겼는지 눈으로 읽을 수 있게 한다. */
|
|
35
|
-
const rows = (n: number, from = 1) =>
|
|
36
|
-
Array.from({ length: n }, (_, i) => ({
|
|
37
|
-
id: `m${from + i}`,
|
|
38
|
-
role: (from + i) % 2 ? 'user' : 'assistant',
|
|
39
|
-
content: `말 ${from + i}`
|
|
40
|
-
}))
|
|
41
|
-
|
|
42
|
-
/** 요약기를 가로채 **무엇이 요약 대상이 됐는지** 본다. 이것이 이 시험의 핵심 관측점이다. */
|
|
43
|
-
function capturing() {
|
|
44
|
-
const seen: string[] = []
|
|
45
|
-
return {
|
|
46
|
-
seen,
|
|
47
|
-
summarize: async (content: string) => {
|
|
48
|
-
seen.push(content)
|
|
49
|
-
return '요약'
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
test('창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다', async () => {
|
|
55
|
-
/* m1..m9 가 창(요약 뒤부터)이다. 마지막 5개는 원문 유지, 앞 4개가 새로 밀려났다. */
|
|
56
|
-
const cap = capturing()
|
|
57
|
-
const r = await foldHistorySummary({
|
|
58
|
-
rows: rows(9),
|
|
59
|
-
rowsStartAfterSummary: true,
|
|
60
|
-
stored: { text: '앞선 요약', upToMessageId: 'm0-요약에-담긴-것' },
|
|
61
|
-
maxTurns: MAX_TURNS,
|
|
62
|
-
threshold: THRESHOLD,
|
|
63
|
-
summarize: cap.summarize
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
assert.ok(r.patch, '4개가 밀려났고 임계치 3 이상이므로 접는다')
|
|
67
|
-
assert.equal(r.patch!.summaryUpToMessageId, 'm4', '밀려난 마지막까지 담았다고 기록한다')
|
|
68
|
-
assert.match(cap.seen[0], /말 1/, '창의 첫 메시지가 요약에 들어간다')
|
|
69
|
-
assert.match(cap.seen[0], /말 4/)
|
|
70
|
-
assert.doesNotMatch(cap.seen[0], /말 5/, '유지분은 원문으로 남으므로 요약에 넣지 않는다')
|
|
71
|
-
assert.match(cap.seen[0], /앞선 요약/, '앞선 요약에 이어 붙인다 — 앞부분을 잃지 않는다')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('전체를 읽어 왔으면 워터마크로 자른다 — 이미 담은 것을 두 번 담지 않는다', async () => {
|
|
75
|
-
/* m1..m14 전체. m5 까지 요약에 담겼다 → 미반영분은 m6..m9(마지막 5개는 유지분). */
|
|
76
|
-
const cap = capturing()
|
|
77
|
-
const r = await foldHistorySummary({
|
|
78
|
-
rows: rows(14),
|
|
79
|
-
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
80
|
-
maxTurns: MAX_TURNS,
|
|
81
|
-
threshold: THRESHOLD,
|
|
82
|
-
summarize: cap.summarize
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
assert.ok(r.patch)
|
|
86
|
-
assert.equal(r.patch!.summaryUpToMessageId, 'm9')
|
|
87
|
-
assert.doesNotMatch(cap.seen[0], /말 5(?!\d)/, 'm5 는 이미 요약에 담겼다')
|
|
88
|
-
assert.match(cap.seen[0], /말 6/)
|
|
89
|
-
assert.match(cap.seen[0], /말 9/)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('읽는 범위를 바꿔도 요약 대상은 같다 — 성능을 바꾼 것이 내용을 바꾸지 않는다', async () => {
|
|
93
|
-
/*
|
|
94
|
-
* 같은 상황을 두 방식으로 읽는다. 전체는 m1..m14 이고 m5 까지 요약됨. 창은 그 뒤인 m6..m14.
|
|
95
|
-
* 둘의 요약 대상이 다르면, 이 최적화가 사실을 옮긴 것이다.
|
|
96
|
-
*/
|
|
97
|
-
const whole = capturing()
|
|
98
|
-
const windowed = capturing()
|
|
99
|
-
|
|
100
|
-
const a = await foldHistorySummary({
|
|
101
|
-
rows: rows(14),
|
|
102
|
-
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
103
|
-
maxTurns: MAX_TURNS,
|
|
104
|
-
threshold: THRESHOLD,
|
|
105
|
-
summarize: whole.summarize
|
|
106
|
-
})
|
|
107
|
-
const b = await foldHistorySummary({
|
|
108
|
-
rows: rows(9, 6),
|
|
109
|
-
rowsStartAfterSummary: true,
|
|
110
|
-
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
111
|
-
maxTurns: MAX_TURNS,
|
|
112
|
-
threshold: THRESHOLD,
|
|
113
|
-
summarize: windowed.summarize
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
assert.equal(whole.seen[0], windowed.seen[0], '요약에 들어가는 글이 글자까지 같다')
|
|
117
|
-
assert.deepEqual(a.patch, b.patch, '기록하는 워터마크도 같다')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
test('워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리지 않는다', () => {
|
|
121
|
-
/* 삭제·편집으로 그 id 가 이력에 없을 수 있다. 그때 덮은 범위를 알 수 없으므로 전부 다시 본다. */
|
|
122
|
-
const { dropped } = splitByCap(rows(14), MAX_TURNS)
|
|
123
|
-
const pending = pendingForSummary(dropped, { text: '앞선 요약', upToMessageId: '지워진-id' })
|
|
124
|
-
|
|
125
|
-
assert.equal(pending.length, dropped.length, '전부 미반영')
|
|
126
|
-
assert.equal(pending[0].id, 'm1')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test('창이 유지분보다 짧으면 접지 않는다 — 새로 밀려난 것이 없다', async () => {
|
|
130
|
-
const cap = capturing()
|
|
131
|
-
const r = await foldHistorySummary({
|
|
132
|
-
rows: rows(MAX_TURNS),
|
|
133
|
-
rowsStartAfterSummary: true,
|
|
134
|
-
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
135
|
-
maxTurns: MAX_TURNS,
|
|
136
|
-
threshold: THRESHOLD,
|
|
137
|
-
summarize: cap.summarize
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
assert.equal(r.patch, null)
|
|
141
|
-
assert.equal(r.skipped, 'nothing-dropped')
|
|
142
|
-
assert.equal(cap.seen.length, 0, 'LLM 을 부르지 않는다')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('밀려난 것이 임계치 미만이면 접지 않는다 — 매 턴 요약하지 않는다', async () => {
|
|
146
|
-
const cap = capturing()
|
|
147
|
-
const r = await foldHistorySummary({
|
|
148
|
-
rows: rows(MAX_TURNS + THRESHOLD - 1),
|
|
149
|
-
rowsStartAfterSummary: true,
|
|
150
|
-
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
151
|
-
maxTurns: MAX_TURNS,
|
|
152
|
-
threshold: THRESHOLD,
|
|
153
|
-
summarize: cap.summarize
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
assert.equal(r.skipped, 'below-threshold')
|
|
157
|
-
assert.equal(cap.seen.length, 0)
|
|
158
|
-
})
|