dsh-context-compression-improved 0.5.1 → 0.5.3

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.
Files changed (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -1,231 +0,0 @@
1
- /**
2
- * TokenPilot-inspired R4: the human-gated review queue.
3
- *
4
- * One durable session record per session holds every live proposal (pending or
5
- * approved). Records carry metadata only — ids, seqs, digests, token counts,
6
- * statuses, and reason codes — never message content, mirroring the upstream
7
- * Cleaner persistence rule. The store face is a minimal KV pair so the queue
8
- * is host-agnostic: the runtime wiring attempts the `storageDomain` seam and
9
- * degrades to an in-memory Map (restart-lossy, acceptable for a short-lived
10
- * pending queue) whenever the seam is absent or fails.
11
- *
12
- * The human-side status set is `pending / approved / rejected / ignored /
13
- * expired`, orthogonal to the execution-side receipt states
14
- * (`applied / deferred`) recorded when an approved batch actually lands.
15
- */
16
- import type { ProposalKind, ProposalSkeleton } from './proposal.ts'
17
-
18
- /** Human-side proposal status. */
19
- export type ReviewProposalStatus = 'pending' | 'approved' | 'rejected' | 'ignored' | 'expired'
20
-
21
- /** Execution-side receipt built only from real mutation evidence (never estimated). */
22
- export interface ReviewReceipt {
23
- readonly status: 'applied' | 'deferred'
24
- /** Reason code for deferred receipts, aligned with the upstream naming. */
25
- readonly reasonCode?: string
26
- /** Predicted recovery from the benefit model at enqueue time. */
27
- readonly estimatedTokens: number
28
- /** Measured recovery of the landed mutation; present only on applied. */
29
- readonly appliedTokens?: number
30
- /** Canonical ISO timestamp of the execution evidence. */
31
- readonly updatedAt: string
32
- }
33
-
34
- /** One queued proposal: persisted metadata, never content. */
35
- export interface ReviewProposalRecord {
36
- readonly id: string
37
- readonly sessionId: string
38
- readonly kind: ProposalKind
39
- readonly items: readonly {
40
- readonly seq: number
41
- readonly component: string
42
- readonly kind: ProposalKind
43
- readonly tokensBefore: number
44
- readonly tokensAfter: number
45
- readonly digest: string
46
- }[]
47
- readonly benefit: {
48
- readonly recoveredTokens: number
49
- readonly penaltyTokens: number
50
- readonly paybackTurns?: number
51
- readonly expectedSaving?: number
52
- }
53
- status: ReviewProposalStatus
54
- readonly enqueuedTurn: number
55
- lastTurnIndex: number
56
- }
57
-
58
- /** Whole-session record: one durable KV value per session. */
59
- export interface ReviewSessionRecord {
60
- readonly version: 1
61
- readonly proposals: readonly ReviewProposalRecord[]
62
- }
63
-
64
- /** A settled proposal: the live record plus its execution receipt. */
65
- export interface ReviewReceiptRecord extends ReviewProposalRecord {
66
- readonly receipt: ReviewReceipt
67
- }
68
-
69
- /** Minimal KV face the queue persists through. */
70
- export interface ReviewQueueStore {
71
- load(sessionId: string): ReviewSessionRecord | undefined
72
- save(sessionId: string, record: ReviewSessionRecord): void
73
- /** Session ids with live records; optional (aggregate reads degrade to none). */
74
- ids?(): readonly string[]
75
- }
76
-
77
- /** In-memory store: the fail-open fallback when no durable seam is available. */
78
- export class MemoryReviewStore implements ReviewQueueStore {
79
- private readonly sessions = new Map<string, ReviewSessionRecord>()
80
-
81
- load(sessionId: string): ReviewSessionRecord | undefined {
82
- return this.sessions.get(sessionId)
83
- }
84
-
85
- save(sessionId: string, record: ReviewSessionRecord): void {
86
- this.sessions.set(sessionId, record)
87
- }
88
-
89
- ids(): readonly string[] {
90
- return [...this.sessions.keys()]
91
- }
92
- }
93
-
94
- export interface ReviewQueueOptions {
95
- /** Pending proposals older than this many turns (since lastTurnIndex) expire. */
96
- readonly timeoutTurns: number
97
- }
98
-
99
- /** Decision outcomes for one decide call. */
100
- export type DecideOutcome =
101
- | { readonly ok: true }
102
- | { readonly ok: false, readonly reason: 'unknown-proposal' | 'not-pending' }
103
-
104
- export class ReviewQueue {
105
- constructor(
106
- private readonly store: ReviewQueueStore,
107
- private readonly options: ReviewQueueOptions,
108
- ) {}
109
-
110
- /**
111
- * Fail-open store access: a throwing seam must never break the compression
112
- * pipeline. Reads degrade to "no stored record"; writes degrade to losing
113
- * durability for that call (the store itself is expected to warn).
114
- */
115
- private safeLoad(sessionId: string): ReviewSessionRecord | undefined {
116
- try {
117
- return this.store.load(sessionId)
118
- } catch {
119
- return undefined
120
- }
121
- }
122
-
123
- private safeSave(sessionId: string, record: ReviewSessionRecord): void {
124
- try {
125
- this.store.save(sessionId, record)
126
- } catch {
127
- // Durability lost for this write; the queue stays functional in memory.
128
- }
129
- }
130
-
131
- private sessionRecord(sessionId: string): ReviewSessionRecord {
132
- return this.safeLoad(sessionId) ?? { version: 1, proposals: [] }
133
- }
134
-
135
- /**
136
- * Queue one classified proposal. A repeated classification of the same
137
- * content re-does nothing but refresh the patience clock, so re-enqueue
138
- * cannot duplicate a live proposal.
139
- * @returns `false` when an identical live proposal already exists.
140
- */
141
- enqueue(sessionId: string, skeleton: ProposalSkeleton, turnIndex: number): boolean {
142
- const record = this.sessionRecord(sessionId)
143
- const existing = record.proposals.find(entry => entry.id === skeleton.id)
144
- if (existing !== undefined && existing.status !== 'expired') {
145
- existing.lastTurnIndex = turnIndex
146
- this.safeSave(sessionId, record)
147
- return false
148
- }
149
- const proposal: ReviewProposalRecord = {
150
- id: skeleton.id,
151
- sessionId,
152
- kind: skeleton.kind,
153
- items: skeleton.items.map(item => ({ ...item })),
154
- benefit: { ...skeleton.benefit },
155
- status: 'pending',
156
- enqueuedTurn: turnIndex,
157
- lastTurnIndex: turnIndex,
158
- }
159
- this.safeSave(sessionId, {
160
- version: 1,
161
- // Expired duplicates are dropped: the fresh skeleton re-enters as pending.
162
- proposals: [...record.proposals.filter(entry => entry.id !== skeleton.id), proposal],
163
- })
164
- return true
165
- }
166
-
167
- /** Live pending proposals of one session, oldest enqueue first. */
168
- listPending(sessionId: string): readonly ReviewProposalRecord[] {
169
- return this.sessionRecord(sessionId).proposals
170
- .filter(entry => entry.status === 'pending')
171
- .sort((left, right) => left.enqueuedTurn - right.enqueuedTurn)
172
- }
173
-
174
- /** Approved proposals waiting for the next turn-boundary batch. */
175
- listApproved(sessionId: string): readonly ReviewProposalRecord[] {
176
- return this.sessionRecord(sessionId).proposals
177
- .filter(entry => entry.status === 'approved')
178
- .sort((left, right) => left.enqueuedTurn - right.enqueuedTurn)
179
- }
180
-
181
- /**
182
- * Transition one pending proposal. Idempotent: deciding an unknown id or a
183
- * non-pending proposal changes nothing and reports the miss.
184
- */
185
- decide(sessionId: string, id: string, decision: 'approved' | 'rejected' | 'ignored'): DecideOutcome {
186
- const record = this.sessionRecord(sessionId)
187
- const proposal = record.proposals.find(entry => entry.id === id)
188
- if (proposal === undefined) return { ok: false, reason: 'unknown-proposal' }
189
- if (proposal.status !== 'pending') return { ok: false, reason: 'not-pending' }
190
- proposal.status = decision
191
- this.safeSave(sessionId, record)
192
- return { ok: true }
193
- }
194
-
195
- /**
196
- * Expire every pending proposal whose patience has run out at this turn
197
- * boundary. Expired proposals are removed from the store (the summary view
198
- * aggregates them from the audit log instead).
199
- * @returns the expired proposals, for the caller's audit emission.
200
- */
201
- expireTurn(sessionId: string, turnIndex: number): readonly ReviewProposalRecord[] {
202
- const record = this.sessionRecord(sessionId)
203
- const keep: ReviewProposalRecord[] = []
204
- const expired: ReviewProposalRecord[] = []
205
- for (const proposal of record.proposals) {
206
- if (proposal.status === 'pending' && turnIndex - proposal.lastTurnIndex > this.options.timeoutTurns) {
207
- expired.push({ ...proposal, status: 'expired' })
208
- continue
209
- }
210
- keep.push(proposal)
211
- }
212
- if (expired.length > 0) this.safeSave(sessionId, { version: 1, proposals: keep })
213
- return expired
214
- }
215
-
216
- /**
217
- * Settle an approved proposal with its execution receipt and retire it from
218
- * the live store. The caller is responsible for auditing the receipt; the
219
- * queue only records which proposal left and why.
220
- */
221
- recordReceipt(sessionId: string, id: string, receipt: ReviewReceipt): ReviewReceiptRecord | undefined {
222
- const record = this.sessionRecord(sessionId)
223
- const proposal = record.proposals.find(entry => entry.id === id)
224
- if (proposal === undefined || proposal.status !== 'approved') return undefined
225
- this.safeSave(sessionId, {
226
- version: 1,
227
- proposals: record.proposals.filter(entry => entry.id !== id),
228
- })
229
- return { ...proposal, receipt }
230
- }
231
- }
@@ -1,117 +0,0 @@
1
- /**
2
- * Scope-independent handle on the live review pipeline.
3
- *
4
- * The R4 HTTP routes are registered on the plugin's TOP-LEVEL fiber
5
- * (`cordis.patch.yml` → `context-compression-improved-estimator-catalog`),
6
- * but every `ToolResultPruner` is mounted inside an agent preset's isolated
7
- * group — `canonicalCompressionRows()` declares
8
- * `isolate: { compaction: true, toolResultPruner: true }` — so
9
- * `ctx.get('toolResultPruner')` at the top level is always `undefined` and the
10
- * queue route could only ever answer 503 "review pipeline unavailable".
11
- *
12
- * Ownership, not transport, was in the wrong place: the queue records are
13
- * already keyed by session id, so the store belongs to the plugin rather than
14
- * to one pruner instance. Every instance shares one store and publishes itself
15
- * here, which lets a top-level reader reach whichever instance currently holds
16
- * a session's proposals.
17
- *
18
- * The durable seam already behaves this way — `REVIEW_STORAGE_DOMAIN` /
19
- * `REVIEW_STORAGE_TABLE` are constants, so every instance opens the same table.
20
- * Only the in-memory fallback was per-instance, and that is what this module
21
- * makes shared.
22
- *
23
- * @module dsh-context-compression-improved/review-registry
24
- */
25
-
26
- import { MemoryReviewStore, type ReviewQueueStore } from './review-queue.ts'
27
-
28
- /**
29
- * The review faces the HTTP routes consume — structural, so the concrete
30
- * `ToolResultPruner` (a Cordis service with a far wider surface) satisfies it
31
- * without this module depending on the runtime class.
32
- */
33
- export interface ReviewPrunerFace {
34
- listReviewProposals(session: unknown): readonly {
35
- readonly id: string
36
- readonly kind: string
37
- readonly items: readonly {
38
- readonly seq: number
39
- readonly kind: string
40
- readonly component: string
41
- readonly tokensBefore: number
42
- readonly tokensAfter: number
43
- }[]
44
- readonly benefit: {
45
- readonly recoveredTokens: number
46
- readonly penaltyTokens: number
47
- readonly paybackTurns?: number
48
- readonly expectedSaving?: number
49
- }
50
- readonly enqueuedTurn: number
51
- readonly lastTurnIndex: number
52
- }[]
53
- decideReviewProposal(
54
- session: unknown,
55
- proposalId: string,
56
- decision: 'approved' | 'rejected' | 'ignored',
57
- ): { ok: true } | { ok: false, reason: string } | undefined
58
- /** Aggregate pending read; absent on older builds (routes then degrade to 503). */
59
- listAllReviewProposals?(): readonly {
60
- readonly sessionId: string
61
- readonly proposals: readonly {
62
- readonly id: string
63
- readonly kind: string
64
- readonly items: readonly { readonly seq: number; readonly kind: string; readonly component: string; readonly tokensBefore: number; readonly tokensAfter: number }[]
65
- readonly benefit: { readonly recoveredTokens: number; readonly penaltyTokens: number; readonly paybackTurns?: number; readonly expectedSaving?: number }
66
- readonly enqueuedTurn: number
67
- readonly lastTurnIndex: number
68
- }[]
69
- }[]
70
- reviewSummary?(session: unknown): {
71
- readonly autoApplied: number
72
- readonly reviewApplied: number
73
- readonly expired: number
74
- readonly voided: number
75
- }
76
- }
77
-
78
- /**
79
- * The one in-memory fallback every pruner instance starts from. Session ids are
80
- * globally unique and `ReviewSessionRecord` is keyed by them, so a single store
81
- * is semantically identical to one store per instance — except that a reader
82
- * reaching any instance now observes every session.
83
- */
84
- const sharedStore: ReviewQueueStore = new MemoryReviewStore()
85
-
86
- const live = new Set<ReviewPrunerFace>()
87
-
88
- /** The process-wide review queue store shared by every pruner instance. */
89
- export function sharedReviewStore(): ReviewQueueStore {
90
- return sharedStore
91
- }
92
-
93
- /**
94
- * Publish one pruner instance for scope-independent readers.
95
- * @param pruner - the instance to publish.
96
- * @returns the disposer removing it, for `ctx.effect`.
97
- */
98
- export function registerReviewPruner(pruner: ReviewPrunerFace): () => void {
99
- live.add(pruner)
100
- return () => {
101
- live.delete(pruner)
102
- }
103
- }
104
-
105
- /**
106
- * Resolve a live pruner instance for the top-level routes.
107
- *
108
- * Any instance can serve an aggregate read because the store is shared, and a
109
- * session-scoped read is answered from that same store. Instances that have
110
- * upgraded to the durable seam read the same table, so the answer does not
111
- * depend on which instance this happens to return.
112
- *
113
- * @returns a live instance, or `undefined` when no preset has been composed yet.
114
- */
115
- export function resolveReviewPruner(): ReviewPrunerFace | undefined {
116
- return live.values().next().value
117
- }
@@ -1,122 +0,0 @@
1
- /**
2
- * TokenPilot-inspired R4: the storageDomain adapter for the review queue.
3
- *
4
- * The `storageDomain` seam is resolved OPTIONALLY at runtime (`ctx.get`), never
5
- * declared as a hard plugin inject: a host without storage backends must load
6
- * the plugin anyway and serve the review queue from its in-memory fallback.
7
- * Any open failure degrades the same way — the caller receives `undefined` and
8
- * logs one warning.
9
- *
10
- * The domain spec is a plain structural object with a hand-rolled `safeParse`
11
- * validator, so the plugin carries no runtime dependency on
12
- * `@deepseek-ai/dsh-storage-domain` (or on a compatible zod instance); hosts
13
- * that reject the structural spec simply fall into the same degrade path.
14
- */
15
- import type { ReviewQueueStore, ReviewSessionRecord } from './review-queue.ts'
16
-
17
- /** Domain name — `UNIT_NAME_RE` (`/^[a-z][a-z0-9_]*$/`) allows no hyphens. */
18
- export const REVIEW_STORAGE_DOMAIN = 'context_compression_review'
19
-
20
- /** The one declared table: one record per session id. */
21
- export const REVIEW_STORAGE_TABLE = 'sessions'
22
-
23
- /** Minimal structural face of one opened domain table (sync reads, durable writes). */
24
- interface ReviewStorageTableLike {
25
- get(key: string): unknown
26
- put(key: string, value: unknown): Promise<void>
27
- keys(): IterableIterator<string>
28
- }
29
-
30
- /** Minimal structural face of the `storageDomain` service. */
31
- interface StorageDomainServiceLike {
32
- open(spec: unknown): Promise<{ table(name: string): ReviewStorageTableLike }>
33
- }
34
-
35
- /** Structural validator: accepts exactly the shape this module persists. */
36
- function reviewSessionRecordValidator(): { safeParse(value: unknown): { success: boolean, data?: ReviewSessionRecord } } {
37
- return {
38
- safeParse(value: unknown): { success: boolean, data?: ReviewSessionRecord } {
39
- if (typeof value !== 'object' || value === null) return { success: false }
40
- const record = value as { version?: unknown, proposals?: unknown }
41
- if (record.version !== 1 || !Array.isArray(record.proposals)) return { success: false }
42
- for (const proposal of record.proposals) {
43
- if (typeof proposal !== 'object' || proposal === null) return { success: false }
44
- const entry = proposal as {
45
- id?: unknown, sessionId?: unknown, kind?: unknown, status?: unknown,
46
- items?: unknown, benefit?: unknown, enqueuedTurn?: unknown, lastTurnIndex?: unknown,
47
- }
48
- if (typeof entry.id !== 'string' || typeof entry.sessionId !== 'string') return { success: false }
49
- if (entry.kind !== 'estimator' && entry.kind !== 'dedup' && entry.kind !== 'read-state') {
50
- return { success: false }
51
- }
52
- if (entry.status !== 'pending' && entry.status !== 'approved') return { success: false }
53
- if (!Number.isSafeInteger(entry.enqueuedTurn) || !Number.isSafeInteger(entry.lastTurnIndex)) {
54
- return { success: false }
55
- }
56
- if (!Array.isArray(entry.items) || typeof entry.benefit !== 'object' || entry.benefit === null) {
57
- return { success: false }
58
- }
59
- for (const item of entry.items) {
60
- if (typeof item !== 'object' || item === null) return { success: false }
61
- const one = item as { seq?: unknown, digest?: unknown }
62
- if (!Number.isSafeInteger(one.seq) || typeof one.digest !== 'string') return { success: false }
63
- }
64
- }
65
- return { success: true, data: value as ReviewSessionRecord }
66
- },
67
- }
68
- }
69
-
70
- function reviewStorageSpec(): unknown {
71
- return {
72
- name: REVIEW_STORAGE_DOMAIN,
73
- version: 1,
74
- layout: 'per-record',
75
- tables: {
76
- [REVIEW_STORAGE_TABLE]: { valueSchema: reviewSessionRecordValidator() },
77
- },
78
- }
79
- }
80
-
81
- /** Adapter presenting the sync KV face the queue expects over the domain table. */
82
- class StorageDomainReviewStore implements ReviewQueueStore {
83
- constructor(private readonly table: ReviewStorageTableLike) {}
84
-
85
- load(sessionId: string): ReviewSessionRecord | undefined {
86
- const value = this.table.get(sessionId)
87
- return typeof value === 'object' && value !== null ? value as ReviewSessionRecord : undefined
88
- }
89
-
90
- save(sessionId: string, record: ReviewSessionRecord): void {
91
- // Durability is fire-and-forget: the domain's write chain lands the record
92
- // while the queue proceeds; failures are logged by the host backend and
93
- // the in-memory state still serves reads.
94
- void this.table.put(sessionId, record).catch(() => undefined)
95
- }
96
-
97
- ids(): readonly string[] {
98
- return [...this.table.keys()]
99
- }
100
- }
101
-
102
- /**
103
- * Attempt to open the review storage domain through the optional
104
- * `storageDomain` seam.
105
- * @param getService - resolved once with the seam name; `undefined` means the
106
- * host lacks the service.
107
- * @returns the durable store, or `undefined` when the seam is absent or fails
108
- * (the caller falls back to the in-memory store and logs one warning).
109
- */
110
- export async function openReviewStorage(
111
- getService: (name: string) => unknown,
112
- ): Promise<ReviewQueueStore | undefined> {
113
- let service: unknown
114
- try {
115
- service = getService('storageDomain')
116
- } catch {
117
- return undefined
118
- }
119
- if (service === undefined || service === null) return undefined
120
- const domain = await (service as StorageDomainServiceLike).open(reviewStorageSpec())
121
- return new StorageDomainReviewStore(domain.table(REVIEW_STORAGE_TABLE))
122
- }
@@ -1,118 +0,0 @@
1
- // @vitest-environment jsdom
2
-
3
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
- import { afterEach, describe, expect, it, vi } from 'vitest'
5
- import { ReviewOverlay } from '../src/client/ReviewOverlay.tsx'
6
- import type { SettingsScopeLike } from '../src/client/review-scope.ts'
7
-
8
- afterEach(() => {
9
- cleanup()
10
- vi.unstubAllGlobals()
11
- })
12
-
13
- const QUEUE_ROUTE = '/api/dsh-context-compression-improved/review-queue'
14
- const DECIDE_ROUTE = '/api/dsh-context-compression-improved/review-decide'
15
-
16
- const PROPOSAL = {
17
- sessionId: 's1',
18
- id: 'abc123def456',
19
- kind: 'read-state',
20
- items: [{ seq: 6, tokensBefore: 2401, tokensAfter: 134 }],
21
- benefit: { recoveredTokens: 400, paybackTurns: 2.25, expectedSaving: 312 },
22
- }
23
-
24
- function scopeStub(reviewMode: boolean): SettingsScopeLike {
25
- return {
26
- getSnapshot: () => ({
27
- status: 'ready',
28
- value: { presetOptions: { reviewMode } },
29
- }),
30
- subscribe: () => () => {},
31
- }
32
- }
33
-
34
- type FetchCall = { input: string | URL | Request, init?: RequestInit | undefined }
35
-
36
- function stubFetch(responses: Array<{ match: (input: string) => boolean, body: unknown, status?: number }>): {
37
- calls: FetchCall[]
38
- } {
39
- const calls: FetchCall[] = []
40
- vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
41
- calls.push({ input, init })
42
- const url = String(input)
43
- const match = responses.find(entry => entry.match(url))
44
- return {
45
- ok: (match?.status ?? 200) < 400,
46
- status: match?.status ?? 200,
47
- json: async () => match?.body,
48
- } as Response
49
- }))
50
- return { calls }
51
- }
52
-
53
- const queueBody = {
54
- ok: true,
55
- total: 1,
56
- pending: [PROPOSAL],
57
- summary: { autoApplied: 2, reviewApplied: 1, expired: 3, voided: 0 },
58
- }
59
-
60
- describe('review overlay (client)', () => {
61
- it('renders the pending list and the four-state summary row', async () => {
62
- stubFetch([{ match: url => url.includes(QUEUE_ROUTE), body: queueBody }])
63
- render(<ReviewOverlay scope={scopeStub(true)} t={key => key} />)
64
-
65
- await waitFor(() => { expect(screen.getByText('review.badge 1')).toBeDefined() })
66
- fireEvent.click(screen.getByText('review.badge 1'))
67
- expect(screen.getByText('review.title')).toBeDefined()
68
- expect(screen.getByText(/review.summary.autoApplied: 2/)).toBeDefined()
69
- expect(screen.getByText(/review.summary.expired: 3/)).toBeDefined()
70
- // Estimated saving is labelled as an estimate.
71
- expect(screen.getByText(/review.row.estimated/)).toBeDefined()
72
- })
73
-
74
- it('posts the three decisions through the decide route and refreshes', async () => {
75
- const { calls } = stubFetch([
76
- { match: url => url.includes(QUEUE_ROUTE), body: queueBody },
77
- { match: url => url.includes(DECIDE_ROUTE), body: { ok: true } },
78
- ])
79
- render(<ReviewOverlay scope={scopeStub(true)} t={key => key} />)
80
- await waitFor(() => { expect(screen.getByText('review.badge 1')).toBeDefined() })
81
- fireEvent.click(screen.getByText('review.badge 1'))
82
-
83
- fireEvent.click(screen.getByText('review.action.approve'))
84
- await waitFor(() => {
85
- expect(calls.some(call => String(call.input) === DECIDE_ROUTE && call.init?.method === 'POST')).toBe(true)
86
- })
87
- const posted = JSON.parse(String(calls.find(call => call.init?.method === 'POST')?.init?.body))
88
- expect(posted).toEqual({ sessionId: 's1', proposalId: 'abc123def456', decision: 'approved' })
89
-
90
- fireEvent.click(screen.getByText('review.action.reject'))
91
- await waitFor(() => {
92
- expect(calls.some(call => String(call.input) === DECIDE_ROUTE
93
- && JSON.parse(String(call.init?.body)).decision === 'rejected')).toBe(true)
94
- })
95
- fireEvent.click(screen.getByText('review.action.ignore'))
96
- await waitFor(() => {
97
- expect(calls.some(call => String(call.input) === DECIDE_ROUTE
98
- && JSON.parse(String(call.init?.body)).decision === 'ignored')).toBe(true)
99
- })
100
- })
101
-
102
- it('renders nothing while nothing is pending', async () => {
103
- stubFetch([{ match: url => url.includes(QUEUE_ROUTE), body: { ok: true, total: 0, pending: [] } }])
104
- const { container } = render(<ReviewOverlay scope={scopeStub(true)} t={key => key} />)
105
- await waitFor(() => {
106
- expect((container.querySelector('.dsh-cc-review-badge'))).toBeNull()
107
- })
108
- })
109
-
110
- it('renders nothing while review mode is off', async () => {
111
- const fetchMock = vi.fn()
112
- vi.stubGlobal('fetch', fetchMock)
113
- const { container } = render(<ReviewOverlay scope={scopeStub(false)} t={key => key} />)
114
- await new Promise(resolve => setTimeout(resolve, 30))
115
- expect(container.querySelector('.dsh-cc-review-badge')).toBeNull()
116
- expect(fetchMock).not.toHaveBeenCalled()
117
- })
118
- })