dsh-context-compression-improved 0.3.0 → 0.4.0-beta.1
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/.githooks/pre-push +37 -0
- package/package.json +3 -2
- package/packages/selector/cordis.patch.yml +12 -5
- package/packages/selector/lib/client.d.ts +24 -0
- package/packages/selector/lib/client.js +506 -5
- package/packages/selector/lib/config.js +27 -4
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +229 -1
- package/packages/selector/lib/pruner.d.ts +254 -0
- package/packages/selector/lib/pruner.js +714 -25
- package/packages/selector/package.json +0 -1
- package/packages/selector/src/client/EstimatorControls.tsx +101 -0
- package/packages/selector/src/client/ReviewOverlay.tsx +320 -0
- package/packages/selector/src/client/index.ts +17 -0
- package/packages/selector/src/client/locales.ts +38 -0
- package/packages/selector/src/client/preset-options.ts +1 -0
- package/packages/selector/src/client/review-scope.ts +16 -0
- package/packages/selector/src/client/settings-section.tsx +17 -8
- package/packages/selector/src/index.ts +308 -0
- package/packages/selector/src/profiles.ts +28 -1
- package/packages/selector/src/pruner/state.ts +27 -0
- package/packages/selector/src/pruner.ts +430 -10
- package/packages/selector/src/runtime/audit.ts +27 -0
- package/packages/selector/src/runtime/config.ts +33 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +60 -13
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +223 -0
- package/packages/selector/src/runtime/tokenpilot/review-queue.ts +231 -0
- package/packages/selector/src/runtime/tokenpilot/review-storage.ts +122 -0
- package/packages/selector/src/runtime/types.ts +17 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +3 -2
- package/packages/selector/tests/custom-contract.client.spec.ts +3 -2
- package/packages/selector/tests/preset-options-write.client.spec.ts +34 -1
- package/packages/selector/tests/review-overlay.client.spec.tsx +118 -0
- package/packages/selector/tests/review-routes.host.spec.ts +290 -0
- package/packages/selector/tests/runtime/audit.spec.ts +44 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +23 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +5 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +199 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +313 -0
- package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +168 -0
- package/packages/selector/tests/settings-seat.client.spec.ts +5 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
classifyCandidates,
|
|
4
|
+
computeBenefit,
|
|
5
|
+
proposalId,
|
|
6
|
+
} from '../../../src/runtime/tokenpilot/proposal.ts'
|
|
7
|
+
|
|
8
|
+
/** Reference values follow the spec formulas exactly:
|
|
9
|
+
* R = Σ(before − after); penalty = (1−α)·tail; payback = penalty / (α·R);
|
|
10
|
+
* expectedSaving = α·R·max(0, Ŝ − payback). */
|
|
11
|
+
|
|
12
|
+
describe('computeBenefit', () => {
|
|
13
|
+
it('returns zero recovery and no payback for an empty batch', () => {
|
|
14
|
+
const result = computeBenefit([], { alpha: 0.1, tailTokens: 4000 })
|
|
15
|
+
expect(result.recoveredTokens).toBe(0)
|
|
16
|
+
expect(result.penaltyTokens).toBe(3600)
|
|
17
|
+
expect(result.paybackTurns).toBeUndefined()
|
|
18
|
+
expect(result.expectedSaving).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('computes the single-candidate benefit with a known remaining-turn estimate', () => {
|
|
22
|
+
// R = 4000 − 400 = 3600; penalty = 0.9·4000 = 3600; per-turn = 0.1·3600 = 360
|
|
23
|
+
const result = computeBenefit(
|
|
24
|
+
[{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
|
|
25
|
+
{ alpha: 0.1, tailTokens: 4000, remainingTurns: 12 },
|
|
26
|
+
)
|
|
27
|
+
expect(result.recoveredTokens).toBe(3600)
|
|
28
|
+
expect(result.penaltyTokens).toBeCloseTo(3600)
|
|
29
|
+
expect(result.paybackTurns).toBeCloseTo(10)
|
|
30
|
+
// α·R·max(0, 12 − 10) = 360·2
|
|
31
|
+
expect(result.expectedSaving).toBeCloseTo(720)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('omits expectedSaving while Ŝ is unknown', () => {
|
|
35
|
+
const result = computeBenefit(
|
|
36
|
+
[{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
|
|
37
|
+
{ alpha: 0.1, tailTokens: 4000 },
|
|
38
|
+
)
|
|
39
|
+
expect(result.paybackTurns).toBeCloseTo(10)
|
|
40
|
+
expect(result.expectedSaving).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('merges candidates into one batch so the penalty is paid once', () => {
|
|
44
|
+
const result = computeBenefit(
|
|
45
|
+
[
|
|
46
|
+
{ sourceSeq: 1, tokensBefore: 2000, tokensAfter: 500 },
|
|
47
|
+
{ sourceSeq: 2, tokensBefore: 3000, tokensAfter: 1000 },
|
|
48
|
+
],
|
|
49
|
+
{ alpha: 0.1, tailTokens: 4000, remainingTurns: 20 },
|
|
50
|
+
)
|
|
51
|
+
expect(result.recoveredTokens).toBe(3500)
|
|
52
|
+
expect(result.penaltyTokens).toBeCloseTo(3600)
|
|
53
|
+
expect(result.paybackTurns).toBeCloseTo(3600 / 350)
|
|
54
|
+
expect(result.expectedSaving).toBeCloseTo(350 * (20 - 3600 / 350))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('clamps a growing candidate to zero recovery instead of negative batch credit', () => {
|
|
58
|
+
const result = computeBenefit(
|
|
59
|
+
[{ sourceSeq: 3, tokensBefore: 100, tokensAfter: 400 }],
|
|
60
|
+
{ alpha: 0.1, tailTokens: 4000 },
|
|
61
|
+
)
|
|
62
|
+
expect(result.recoveredTokens).toBe(0)
|
|
63
|
+
expect(result.paybackTurns).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('stays finite as α → 0: no division, payback undefined, expectedSaving negative', () => {
|
|
67
|
+
const result = computeBenefit(
|
|
68
|
+
[{ sourceSeq: 4, tokensBefore: 4000, tokensAfter: 400 }],
|
|
69
|
+
{ alpha: 0, tailTokens: 4000, remainingTurns: 12 },
|
|
70
|
+
)
|
|
71
|
+
expect(result.recoveredTokens).toBe(3600)
|
|
72
|
+
expect(result.paybackTurns).toBeUndefined()
|
|
73
|
+
// With zero per-turn saving the batch can only lose the refill penalty.
|
|
74
|
+
expect(result.expectedSaving).toBeCloseTo(-4000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('never reports a positive expectedSaving once Ŝ is inside the payback window', () => {
|
|
78
|
+
// payback = 10 turns; Ŝ = 5 → max(0, Ŝ − payback) = 0
|
|
79
|
+
const result = computeBenefit(
|
|
80
|
+
[{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
|
|
81
|
+
{ alpha: 0.1, tailTokens: 4000, remainingTurns: 5 },
|
|
82
|
+
)
|
|
83
|
+
expect(result.expectedSaving).toBeCloseTo(0)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('proposalId', () => {
|
|
88
|
+
it('is stable for the same digest list and length-sensitive', () => {
|
|
89
|
+
const digests = ['aa'.repeat(32), 'bb'.repeat(32)]
|
|
90
|
+
expect(proposalId(digests)).toBe(proposalId([...digests]))
|
|
91
|
+
expect(proposalId(digests)).toHaveLength(12)
|
|
92
|
+
expect(proposalId(digests)).not.toBe(proposalId([digests[0]!, digests[1]!, digests[0]!]))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('distinguishes an empty batch from a single digest', () => {
|
|
96
|
+
expect(proposalId([])).not.toBe(proposalId(['cc'.repeat(32)]))
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** Triage fixtures: α=0.5 keeps the arithmetic obvious —
|
|
101
|
+
* payback = 0.5·tail / (0.5·R) = tail / R. */
|
|
102
|
+
const TRIAGE = { alpha: 0.5, tailTokens: 1000, reviewHighImpactTokens: 4000 }
|
|
103
|
+
|
|
104
|
+
function candidate(overrides: {
|
|
105
|
+
sourceSeq?: number
|
|
106
|
+
tokensBefore?: number
|
|
107
|
+
tokensAfter?: number
|
|
108
|
+
reducer?: string
|
|
109
|
+
component?: string
|
|
110
|
+
}): Parameters<typeof classifyCandidates>[0][number] {
|
|
111
|
+
return {
|
|
112
|
+
sourceSeq: overrides.sourceSeq ?? 1,
|
|
113
|
+
tokensBefore: overrides.tokensBefore ?? 1000,
|
|
114
|
+
tokensAfter: overrides.tokensAfter ?? 500,
|
|
115
|
+
reducer: overrides.reducer ?? 'native-whole-result',
|
|
116
|
+
component: overrides.component ?? 'native-tool-result',
|
|
117
|
+
content: [{ type: 'text', text: 'opaque result body' }],
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('classifyCandidates', () => {
|
|
122
|
+
it('auto-applies the clearly profitable band and drops negative recovery', () => {
|
|
123
|
+
// tail/R = 1000/1000 = 1 → payback = 1 → auto; R = 0 → drop.
|
|
124
|
+
const result = classifyCandidates(
|
|
125
|
+
[candidate({ sourceSeq: 1, tokensBefore: 2000, tokensAfter: 1000 }), candidate({ sourceSeq: 2, tokensBefore: 500, tokensAfter: 500 })],
|
|
126
|
+
TRIAGE,
|
|
127
|
+
)
|
|
128
|
+
expect(result.auto.map(entry => entry.sourceSeq)).toEqual([1])
|
|
129
|
+
expect(result.drop.map(entry => entry.sourceSeq)).toEqual([2])
|
|
130
|
+
expect(result.review).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('sends high-impact candidates to review even inside the auto band', () => {
|
|
134
|
+
// payback = 1000/5000 = 0.2 ≤ 1, but tokensBefore ≥ 4000 → review ("直接送审").
|
|
135
|
+
const result = classifyCandidates(
|
|
136
|
+
[candidate({ tokensBefore: 5000, tokensAfter: 4000 })],
|
|
137
|
+
TRIAGE,
|
|
138
|
+
)
|
|
139
|
+
expect(result.auto).toEqual([])
|
|
140
|
+
expect(result.review).toHaveLength(1)
|
|
141
|
+
expect(result.review[0]!.items[0]!.tokensBefore).toBe(5000)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('keeps the Ŝ-gated edge band in review and closes it while Ŝ is unknown', () => {
|
|
145
|
+
// payback = 1000/200 = 5: with Ŝ = 40 → 5 ≤ 0.25·40 = 10 → auto.
|
|
146
|
+
const known = classifyCandidates(
|
|
147
|
+
[candidate({ tokensBefore: 1200, tokensAfter: 1000 })],
|
|
148
|
+
{ ...TRIAGE, remainingTurns: 40 },
|
|
149
|
+
)
|
|
150
|
+
expect(known.auto).toHaveLength(1)
|
|
151
|
+
// payback = 1000/50 = 20: with Ŝ = 40, 20 > 0.25·40 and > 3 → drop.
|
|
152
|
+
const slow = classifyCandidates(
|
|
153
|
+
[candidate({ tokensBefore: 1050, tokensAfter: 1000 })],
|
|
154
|
+
{ ...TRIAGE, remainingTurns: 40 },
|
|
155
|
+
)
|
|
156
|
+
expect(slow.drop).toHaveLength(1)
|
|
157
|
+
// Same slow candidate with Ŝ unknown → no edge band, no auto → drop.
|
|
158
|
+
const unknown = classifyCandidates(
|
|
159
|
+
[candidate({ tokensBefore: 1050, tokensAfter: 1000 })],
|
|
160
|
+
TRIAGE,
|
|
161
|
+
)
|
|
162
|
+
expect(unknown.drop).toHaveLength(1)
|
|
163
|
+
// payback = 1000/400 = 2.5: Ŝ = 8 → 2.5 > 0.25·8 = 2 and ≤ 3 → review.
|
|
164
|
+
const edge = classifyCandidates(
|
|
165
|
+
[candidate({ tokensBefore: 1400, tokensAfter: 1000 })],
|
|
166
|
+
{ ...TRIAGE, remainingTurns: 8 },
|
|
167
|
+
)
|
|
168
|
+
expect(edge.review).toHaveLength(1)
|
|
169
|
+
// Same edge candidate with Ŝ unknown → drop.
|
|
170
|
+
const edgeUnknown = classifyCandidates(
|
|
171
|
+
[candidate({ tokensBefore: 1400, tokensAfter: 1000 })],
|
|
172
|
+
TRIAGE,
|
|
173
|
+
)
|
|
174
|
+
expect(edgeUnknown.drop).toHaveLength(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('freezes digest, kind, and benefit into the review skeleton', () => {
|
|
178
|
+
const result = classifyCandidates(
|
|
179
|
+
[candidate({ sourceSeq: 9, reducer: 'dedupe-pointer', component: 'fresh' })],
|
|
180
|
+
// payback = 1000/500 = 2: Ŝ = 6 → 2 > 0.25·6 = 1.5 and ≤ 3 → review.
|
|
181
|
+
{ ...TRIAGE, remainingTurns: 6 },
|
|
182
|
+
)
|
|
183
|
+
const skeleton = result.review[0]!
|
|
184
|
+
expect(skeleton.kind).toBe('dedup')
|
|
185
|
+
expect(skeleton.items[0]!.seq).toBe(9)
|
|
186
|
+
expect(skeleton.items[0]!.digest).toMatch(/^[0-9a-f]{64}$/)
|
|
187
|
+
expect(skeleton.items[0]!.kind).toBe('dedup')
|
|
188
|
+
expect(skeleton.benefit.recoveredTokens).toBe(500)
|
|
189
|
+
expect(skeleton.id).toEqual(proposalId([skeleton.items[0]!.digest]))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('labels estimator-channel seqs without changing the triage math', () => {
|
|
193
|
+
const result = classifyCandidates(
|
|
194
|
+
[candidate({ sourceSeq: 11, reducer: 'superseded-read-whole-result', component: 'history' })],
|
|
195
|
+
{ ...TRIAGE, remainingTurns: 6, estimatorSeqs: new Set([11]) },
|
|
196
|
+
)
|
|
197
|
+
expect(result.review[0]!.kind).toBe('estimator')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenPilot-inspired R4 integration coverage: the human-gated review pipeline
|
|
3
|
+
* on published Harness APIs. Mirrors the public-runtime harness (real
|
|
4
|
+
* Session/log, exact tokenizer, host settings) and pins the four acceptance
|
|
5
|
+
* behaviors from the phase-1 spec: approved proposals execute as one merged
|
|
6
|
+
* batch at the turn boundary with evidence-built receipts; tampered content
|
|
7
|
+
* voids instead of deleting; expired proposals never execute; review-off
|
|
8
|
+
* behavior stays byte-identical to the automatic path.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import {
|
|
13
|
+
ToolCallId as CallId,
|
|
14
|
+
createMessage,
|
|
15
|
+
createUserMessage,
|
|
16
|
+
createToolResultMessage,
|
|
17
|
+
freezeMessage,
|
|
18
|
+
} from '@deepseek-ai/dsh-llm'
|
|
19
|
+
import SessionStore, {
|
|
20
|
+
Session,
|
|
21
|
+
SessionId,
|
|
22
|
+
SessionSeq,
|
|
23
|
+
canonicalHeader,
|
|
24
|
+
} from '@deepseek-ai/dsh-session'
|
|
25
|
+
import {
|
|
26
|
+
SettingsProvider,
|
|
27
|
+
type SettingsNamespace,
|
|
28
|
+
} from '@deepseek-ai/dsh-settings'
|
|
29
|
+
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
30
|
+
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
31
|
+
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
|
32
|
+
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
|
33
|
+
import * as SelectorHost from '../../../src/index.ts'
|
|
34
|
+
import { sessionEvents } from '../../../src/runtime/session-events.ts'
|
|
35
|
+
import ToolResultPruner, {
|
|
36
|
+
CONTEXT_COMPRESSION_SETTINGS_NAMESPACE,
|
|
37
|
+
} from '../../../src/pruner.ts'
|
|
38
|
+
import { measureForCompaction } from '../../../src/runtime/measurement.ts'
|
|
39
|
+
import {
|
|
40
|
+
COMPRESSION_AUDIT_PREFIX,
|
|
41
|
+
type CompressionAuditRecord,
|
|
42
|
+
type CompressionRewriteAuditRecord,
|
|
43
|
+
type ReviewOutcomeAuditRecord,
|
|
44
|
+
} from '../../../src/runtime/audit.ts'
|
|
45
|
+
|
|
46
|
+
const MODEL = 'deepseek-v4-flash'
|
|
47
|
+
|
|
48
|
+
class TestSettings extends SettingsProvider {
|
|
49
|
+
readonly writable = true
|
|
50
|
+
private readonly stored: Record<string, unknown> = {}
|
|
51
|
+
|
|
52
|
+
protected override load(): Promise<Record<string, unknown>> {
|
|
53
|
+
return Promise.resolve(structuredClone(this.stored))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected override persist(namespace: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
|
57
|
+
this.stored[namespace] = structuredClone(section)
|
|
58
|
+
return Promise.resolve()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runtimeContext(): Promise<Context> {
|
|
63
|
+
const ctx = new Context()
|
|
64
|
+
await ctx.plugin(SessionStore).await()
|
|
65
|
+
await ctx.plugin(SystemPrompt).await()
|
|
66
|
+
await ctx.plugin(ToolRuntime).await()
|
|
67
|
+
await ctx.plugin(SessionProjectionRegistry).await()
|
|
68
|
+
await ctx.plugin(TokenMeter).await()
|
|
69
|
+
return ctx
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function captureAudit(ctx: Context): { records(): CompressionAuditRecord[] } {
|
|
73
|
+
const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => ctx.logger)
|
|
74
|
+
return {
|
|
75
|
+
records: () => info.mock.calls.flatMap((call) => {
|
|
76
|
+
const line = String(call[0])
|
|
77
|
+
return line.startsWith(COMPRESSION_AUDIT_PREFIX)
|
|
78
|
+
? [JSON.parse(line.slice(COMPRESSION_AUDIT_PREFIX.length)) as CompressionAuditRecord]
|
|
79
|
+
: []
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rewrites(records: readonly CompressionAuditRecord[]): CompressionRewriteAuditRecord[] {
|
|
85
|
+
return records.filter((record): record is CompressionRewriteAuditRecord => record.kind === 'rewrite')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reviewEvents(records: readonly CompressionAuditRecord[]): ReviewOutcomeAuditRecord[] {
|
|
89
|
+
return records.filter((record): record is ReviewOutcomeAuditRecord => record.kind === 'review-outcome')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nsBrand = (value: string): SettingsNamespace => value as unknown as SettingsNamespace
|
|
93
|
+
|
|
94
|
+
function appendToolTurn(
|
|
95
|
+
session: Session,
|
|
96
|
+
turn: number,
|
|
97
|
+
text: string,
|
|
98
|
+
closeTurn: boolean,
|
|
99
|
+
): { readonly assistantSeq: number; readonly resultSeq: number } {
|
|
100
|
+
const callId = CallId(`call-${String(turn)}`)
|
|
101
|
+
session.append('turn/start', { turn })
|
|
102
|
+
if (session.requestHeader() === undefined) {
|
|
103
|
+
session.append('request/header', {
|
|
104
|
+
reason: 'initial',
|
|
105
|
+
header: canonicalHeader({ config: { provider: 'deepseek', model: MODEL } }),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
session.append('user/message', createUserMessage({
|
|
109
|
+
content: [{ type: 'text', text: `user turn ${String(turn)}` }],
|
|
110
|
+
source: { kind: 'user' },
|
|
111
|
+
}), { surfaceOp: 'append' })
|
|
112
|
+
session.append('step/start', { turn, step: 1 })
|
|
113
|
+
const assistant = session.append('assistant/message', {
|
|
114
|
+
stream: [],
|
|
115
|
+
turn,
|
|
116
|
+
step: 1,
|
|
117
|
+
message: createMessage({
|
|
118
|
+
role: 'assistant',
|
|
119
|
+
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
|
120
|
+
source: { kind: 'model', provider: 'deepseek', model: MODEL },
|
|
121
|
+
}),
|
|
122
|
+
}, { surfaceOp: 'append' })
|
|
123
|
+
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
|
|
124
|
+
const result = session.append('tool/result', {
|
|
125
|
+
turn,
|
|
126
|
+
step: 1,
|
|
127
|
+
message: createToolResultMessage({
|
|
128
|
+
callId,
|
|
129
|
+
content: [{ type: 'text', text }],
|
|
130
|
+
isError: false,
|
|
131
|
+
}),
|
|
132
|
+
}, { surfaceOp: 'append' })
|
|
133
|
+
session.append('step/end', { turn, step: 1 })
|
|
134
|
+
if (closeTurn) session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
|
135
|
+
return { assistantSeq: assistant.seq, resultSeq: result.seq }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Rewrite one surface tool result in place, the way land() publishes a replacement. */
|
|
139
|
+
function tamperResult(session: Session, seq: number, text: string): void {
|
|
140
|
+
const event = sessionEvents(session).find(entry => entry.seq === seq)
|
|
141
|
+
if (event?.type !== 'tool/result') throw new Error(`seq ${String(seq)} is not a tool result`)
|
|
142
|
+
const message = event.data.message
|
|
143
|
+
// The session accepts a surface replacement only when every non-content
|
|
144
|
+
// field stays identical, so freeze the original message and swap the blocks.
|
|
145
|
+
session.append('tool/result', {
|
|
146
|
+
...event.data,
|
|
147
|
+
message: freezeMessage({
|
|
148
|
+
...message,
|
|
149
|
+
content: [{ ...message.content[0], content: [{ type: 'text', text }] }],
|
|
150
|
+
}),
|
|
151
|
+
}, {
|
|
152
|
+
surfaceOp: { op: 'replace', startSeq: SessionSeq(seq), endSeq: SessionSeq(seq) },
|
|
153
|
+
sourceEventSeqs: [SessionSeq(seq)],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function reviewSetup(ctx: Context, reviewMode: boolean): Promise<void> {
|
|
158
|
+
await ctx.plugin(TestSettings).await()
|
|
159
|
+
await ctx.plugin(SelectorHost).await()
|
|
160
|
+
await ctx.settings.update(nsBrand(CONTEXT_COMPRESSION_SETTINGS_NAMESPACE), {
|
|
161
|
+
profile: 'tokenpilot-inspired',
|
|
162
|
+
...(reviewMode
|
|
163
|
+
? { presetOptions: { reviewMode: true, reviewHighImpactTokens: 1 } }
|
|
164
|
+
: {}),
|
|
165
|
+
})
|
|
166
|
+
await ctx.plugin(ToolResultPruner, {
|
|
167
|
+
profile: 'tokenpilot-inspired',
|
|
168
|
+
freshTriggerTokens: 100_000,
|
|
169
|
+
freshTargetTokens: 90_000,
|
|
170
|
+
aggregateTriggerTokens: 100_000,
|
|
171
|
+
aggregateTargetTokens: 90_000,
|
|
172
|
+
historyTriggerTokens: 400,
|
|
173
|
+
historyKeepRecentToolCalls: 0,
|
|
174
|
+
historyKeepRecentTokens: 1,
|
|
175
|
+
historyMinReclaimTokens: 1,
|
|
176
|
+
}).await()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One aged old result plus one protected recent result, with an open turn. */
|
|
180
|
+
function reviewSession(ctx: Context, id: string, oldText: string): {
|
|
181
|
+
session: Session
|
|
182
|
+
oldResultSeq: number
|
|
183
|
+
} {
|
|
184
|
+
const pruner = ctx.toolResultPruner
|
|
185
|
+
const session = Session.create(SessionId(id))
|
|
186
|
+
const old = appendToolTurn(session, 1, oldText, true)
|
|
187
|
+
appendToolTurn(session, 2, 'newest protected result', true)
|
|
188
|
+
const total = measureForCompaction(ctx, session).totalTokens
|
|
189
|
+
session.append('request/context', {
|
|
190
|
+
provider: 'deepseek',
|
|
191
|
+
model: MODEL,
|
|
192
|
+
contextWindow: Math.floor(total / 0.6),
|
|
193
|
+
})
|
|
194
|
+
session.append('turn/start', { turn: 3 })
|
|
195
|
+
return { session, oldResultSeq: old.resultSeq }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe('tokenpilot review pipeline (host integration)', () => {
|
|
199
|
+
it('withholds high-impact candidates from landing and queues them for review', async () => {
|
|
200
|
+
const ctx = await runtimeContext()
|
|
201
|
+
await reviewSetup(ctx, true)
|
|
202
|
+
const audit = captureAudit(ctx)
|
|
203
|
+
const { session, oldResultSeq } = reviewSession(ctx, 'review-queue-withhold', 'old reviewable evidence '.repeat(600))
|
|
204
|
+
|
|
205
|
+
const result = ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
206
|
+
|
|
207
|
+
// The old candidate was planned but withheld: nothing landed.
|
|
208
|
+
expect(result.pruned).toHaveLength(0)
|
|
209
|
+
const pending = ctx.toolResultPruner.listReviewProposals(session)
|
|
210
|
+
expect(pending).toHaveLength(1)
|
|
211
|
+
expect(pending[0]!.status).toBe('pending')
|
|
212
|
+
expect(pending[0]!.items[0]!.seq).toBe(oldResultSeq)
|
|
213
|
+
expect(reviewEvents(audit.records()).filter(entry => entry.event === 'enqueue')).toHaveLength(1)
|
|
214
|
+
// And the original content is still on the surface.
|
|
215
|
+
expect(rewrites(audit.records())).toHaveLength(0)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('executes an approved proposal at the turn boundary with an evidence-built receipt', async () => {
|
|
219
|
+
const ctx = await runtimeContext()
|
|
220
|
+
await reviewSetup(ctx, true)
|
|
221
|
+
const audit = captureAudit(ctx)
|
|
222
|
+
const { session } = reviewSession(ctx, 'review-apply-e2e', 'old reviewable evidence '.repeat(600))
|
|
223
|
+
ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
224
|
+
const pending = ctx.toolResultPruner.listReviewProposals(session)
|
|
225
|
+
const proposalId = pending[0]!.id
|
|
226
|
+
|
|
227
|
+
expect(ctx.toolResultPruner.decideReviewProposal(session, proposalId, 'approved')).toEqual({ ok: true })
|
|
228
|
+
expect(reviewEvents(audit.records()).some(entry => entry.event === 'decide' && entry.decision === 'approved')).toBe(true)
|
|
229
|
+
|
|
230
|
+
ctx.toolResultPruner.applyApprovedProposals(session)
|
|
231
|
+
|
|
232
|
+
const applied = rewrites(audit.records()).filter(entry => entry.reducer === 'review-approved-whole-result')
|
|
233
|
+
expect(applied).toHaveLength(1)
|
|
234
|
+
expect(applied[0]!.tokensBefore).toBeGreaterThan(applied[0]!.tokensAfter)
|
|
235
|
+
const receipts = reviewEvents(audit.records()).filter(entry => entry.event === 'apply-receipt')
|
|
236
|
+
expect(receipts).toHaveLength(1)
|
|
237
|
+
expect(receipts[0]!.proposalId).toBe(proposalId)
|
|
238
|
+
expect(receipts[0]!.receiptStatus).toBe('applied')
|
|
239
|
+
// The receipt numbers come from the executed mutation, not the estimate.
|
|
240
|
+
expect(receipts[0]!.tokensBefore).toBe(applied[0]!.tokensBefore)
|
|
241
|
+
expect(receipts[0]!.tokensAfter).toBe(applied[0]!.tokensAfter)
|
|
242
|
+
// Retired from the queue: a second apply must not re-execute.
|
|
243
|
+
expect(ctx.toolResultPruner.listReviewProposals(session)).toHaveLength(0)
|
|
244
|
+
const before = sessionEvents(session).length
|
|
245
|
+
ctx.toolResultPruner.applyApprovedProposals(session)
|
|
246
|
+
expect(sessionEvents(session)).toHaveLength(before)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('voids a proposal whose content changed between approval and the apply point', async () => {
|
|
250
|
+
const ctx = await runtimeContext()
|
|
251
|
+
await reviewSetup(ctx, true)
|
|
252
|
+
const audit = captureAudit(ctx)
|
|
253
|
+
const { session, oldResultSeq } = reviewSession(ctx, 'review-void-tamper', 'old reviewable evidence '.repeat(600))
|
|
254
|
+
ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
255
|
+
const proposalId = ctx.toolResultPruner.listReviewProposals(session)[0]!.id
|
|
256
|
+
// The user approved; a later step then rewrote the same surface content.
|
|
257
|
+
tamperResult(session, oldResultSeq, 'tampered by a later step')
|
|
258
|
+
expect(ctx.toolResultPruner.decideReviewProposal(session, proposalId, 'approved')).toEqual({ ok: true })
|
|
259
|
+
|
|
260
|
+
ctx.toolResultPruner.applyApprovedProposals(session)
|
|
261
|
+
|
|
262
|
+
// No deletion: the digest mismatch voided the whole proposal.
|
|
263
|
+
expect(rewrites(audit.records()).some(entry => entry.reducer === 'review-approved-whole-result')).toBe(false)
|
|
264
|
+
const voids = reviewEvents(audit.records()).filter(entry => entry.event === 'apply-void')
|
|
265
|
+
expect(voids).toHaveLength(1)
|
|
266
|
+
expect(voids[0]!.proposalId).toBe(proposalId)
|
|
267
|
+
expect(voids[0]!.reasonCode).toBe('review_receipt_digest_invalid')
|
|
268
|
+
// The tampered content is untouched on the surface.
|
|
269
|
+
const surface = sessionEvents(session).filter(entry => entry.type === 'tool/result')
|
|
270
|
+
const last = surface.at(-1)
|
|
271
|
+
if (last?.type !== 'tool/result') throw new Error('missing surface tool result')
|
|
272
|
+
expect(last.data.message.content[0].content[0]).toMatchObject({ text: 'tampered by a later step' })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('expires stale pending proposals and never executes them afterwards', async () => {
|
|
276
|
+
const ctx = await runtimeContext()
|
|
277
|
+
await reviewSetup(ctx, true)
|
|
278
|
+
const audit = captureAudit(ctx)
|
|
279
|
+
const { session } = reviewSession(ctx, 'review-expiry', 'old reviewable evidence '.repeat(600))
|
|
280
|
+
ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
281
|
+
const proposalId = ctx.toolResultPruner.listReviewProposals(session)[0]!.id
|
|
282
|
+
|
|
283
|
+
// Within the patience window the proposal survives.
|
|
284
|
+
expect(ctx.toolResultPruner.expireReviewProposals(session, 5)).toHaveLength(0)
|
|
285
|
+
expect(ctx.toolResultPruner.listReviewProposals(session)).toHaveLength(1)
|
|
286
|
+
// Past reviewTimeoutTurns (6) the pending proposal expires at the boundary.
|
|
287
|
+
const expired = ctx.toolResultPruner.expireReviewProposals(session, 8)
|
|
288
|
+
expect(expired.map(entry => entry.id)).toEqual([proposalId])
|
|
289
|
+
expect(reviewEvents(audit.records()).some(entry => entry.event === 'expire')).toBe(true)
|
|
290
|
+
expect(ctx.toolResultPruner.listReviewProposals(session)).toHaveLength(0)
|
|
291
|
+
|
|
292
|
+
// An expired proposal cannot be approved and never executes.
|
|
293
|
+
expect(ctx.toolResultPruner.decideReviewProposal(session, proposalId, 'approved'))
|
|
294
|
+
.toEqual({ ok: false, reason: 'unknown-proposal' })
|
|
295
|
+
ctx.toolResultPruner.applyApprovedProposals(session)
|
|
296
|
+
expect(rewrites(audit.records()).some(entry => entry.reducer === 'review-approved-whole-result')).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('keeps the automatic path byte-identical when review mode is off', async () => {
|
|
300
|
+
const ctx = await runtimeContext()
|
|
301
|
+
await reviewSetup(ctx, false)
|
|
302
|
+
const audit = captureAudit(ctx)
|
|
303
|
+
const { session } = reviewSession(ctx, 'review-off-identity', 'old reviewable evidence '.repeat(600))
|
|
304
|
+
|
|
305
|
+
const result = ctx.toolResultPruner.pruneSession(session, { stage: 'pressure' })
|
|
306
|
+
|
|
307
|
+
// History aged exactly as before the review pipeline existed.
|
|
308
|
+
expect(result.pruned).toHaveLength(1)
|
|
309
|
+
expect(rewrites(audit.records()).some(entry => entry.component === 'history')).toBe(true)
|
|
310
|
+
expect(reviewEvents(audit.records())).toHaveLength(0)
|
|
311
|
+
expect(ctx.toolResultPruner.listReviewProposals(session)).toHaveLength(0)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
MemoryReviewStore,
|
|
4
|
+
ReviewQueue,
|
|
5
|
+
type ReviewQueueStore,
|
|
6
|
+
} from '../../../src/runtime/tokenpilot/review-queue.ts'
|
|
7
|
+
import type { ProposalSkeleton } from '../../../src/runtime/tokenpilot/proposal.ts'
|
|
8
|
+
|
|
9
|
+
const ITEM = {
|
|
10
|
+
seq: 7,
|
|
11
|
+
component: 'history',
|
|
12
|
+
kind: 'read-state',
|
|
13
|
+
tokensBefore: 1400,
|
|
14
|
+
tokensAfter: 1000,
|
|
15
|
+
digest: 'ab'.repeat(32),
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
function skeleton(overrides: Partial<ProposalSkeleton> = {}): ProposalSkeleton {
|
|
19
|
+
return {
|
|
20
|
+
id: overrides.id ?? 'proposal00001',
|
|
21
|
+
kind: overrides.kind ?? 'read-state',
|
|
22
|
+
items: overrides.items ?? [ITEM],
|
|
23
|
+
benefit: overrides.benefit ?? {
|
|
24
|
+
recoveredTokens: 400,
|
|
25
|
+
penaltyTokens: 900,
|
|
26
|
+
paybackTurns: 2.25,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Failing store: every seam failure degrades to memory, never throws. */
|
|
32
|
+
|
|
33
|
+
function makeQueue(timeoutTurns = 6, store: ReviewQueueStore = new MemoryReviewStore()): ReviewQueue {
|
|
34
|
+
return new ReviewQueue(store, { timeoutTurns })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('review queue', () => {
|
|
38
|
+
it('moves a proposal through enqueue → pending → approved', () => {
|
|
39
|
+
const queue = makeQueue()
|
|
40
|
+
expect(queue.enqueue('s1', skeleton(), 3)).toBe(true)
|
|
41
|
+
expect(queue.listPending('s1')).toHaveLength(1)
|
|
42
|
+
expect(queue.listPending('s1')[0]!.items[0]!.digest).toBe(ITEM.digest)
|
|
43
|
+
expect(queue.decide('s1', 'proposal00001', 'approved')).toEqual({ ok: true })
|
|
44
|
+
expect(queue.listPending('s1')).toHaveLength(0)
|
|
45
|
+
expect(queue.listApproved('s1')).toHaveLength(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('supports every decision and keeps sessions isolated', () => {
|
|
49
|
+
const queue = makeQueue()
|
|
50
|
+
queue.enqueue('s1', skeleton({ id: 'p-rejected000' }), 1)
|
|
51
|
+
queue.enqueue('s2', skeleton({ id: 'p-ignored0000' }), 1)
|
|
52
|
+
expect(queue.decide('s1', 'p-rejected000', 'rejected')).toEqual({ ok: true })
|
|
53
|
+
expect(queue.decide('s2', 'p-ignored0000', 'ignored')).toEqual({ ok: true })
|
|
54
|
+
expect(queue.listPending('s1')).toHaveLength(0)
|
|
55
|
+
expect(queue.listPending('s2')).toHaveLength(0)
|
|
56
|
+
expect(queue.listApproved('s1')).toHaveLength(0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('re-enqueue of identical content refreshes patience without duplicating', () => {
|
|
60
|
+
const queue = makeQueue()
|
|
61
|
+
expect(queue.enqueue('s1', skeleton(), 2)).toBe(true)
|
|
62
|
+
expect(queue.enqueue('s1', skeleton(), 5)).toBe(false)
|
|
63
|
+
const pending = queue.listPending('s1')
|
|
64
|
+
expect(pending).toHaveLength(1)
|
|
65
|
+
// Patience clock restarted at the re-enqueue turn.
|
|
66
|
+
expect(pending[0]!.lastTurnIndex).toBe(5)
|
|
67
|
+
expect(pending[0]!.enqueuedTurn).toBe(2)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('decide is idempotent and reports unknown or already-decided ids', () => {
|
|
71
|
+
const queue = makeQueue()
|
|
72
|
+
queue.enqueue('s1', skeleton(), 1)
|
|
73
|
+
expect(queue.decide('s1', 'proposal00001', 'approved')).toEqual({ ok: true })
|
|
74
|
+
expect(queue.decide('s1', 'proposal00001', 'approved')).toEqual({ ok: false, reason: 'not-pending' })
|
|
75
|
+
expect(queue.decide('s1', 'missing-proposal', 'rejected')).toEqual({ ok: false, reason: 'unknown-proposal' })
|
|
76
|
+
expect(queue.listApproved('s1')).toHaveLength(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('expires only pending proposals past the timeout window at the turn boundary', () => {
|
|
80
|
+
const queue = makeQueue(6)
|
|
81
|
+
queue.enqueue('s1', skeleton({ id: 'p-pending-old0' }), 1)
|
|
82
|
+
queue.enqueue('s1', skeleton({ id: 'p-pending-new0' }), 9)
|
|
83
|
+
queue.enqueue('s1', skeleton({ id: 'p-approved-00' }), 1)
|
|
84
|
+
queue.decide('s1', 'p-approved-00', 'approved')
|
|
85
|
+
// Turn 8: the turn-1 pending proposal is 7 turns stale (> 6) → expired;
|
|
86
|
+
// the approved one never expires and the turn-9 one is still fresh.
|
|
87
|
+
const expired = queue.expireTurn('s1', 8)
|
|
88
|
+
expect(expired.map(entry => entry.id)).toEqual(['p-pending-old0'])
|
|
89
|
+
expect(expired[0]!.status).toBe('expired')
|
|
90
|
+
expect(queue.listPending('s1').map(entry => entry.id)).toEqual(['p-pending-new0'])
|
|
91
|
+
expect(queue.listApproved('s1').map(entry => entry.id)).toEqual(['p-approved-00'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('keeps proposals exactly at the timeout boundary', () => {
|
|
95
|
+
const queue = makeQueue(6)
|
|
96
|
+
queue.enqueue('s1', skeleton(), 1)
|
|
97
|
+
// 6 - 1 = 5 ≤ 6 → still pending.
|
|
98
|
+
expect(queue.expireTurn('s1', 6)).toHaveLength(0)
|
|
99
|
+
expect(queue.listPending('s1')).toHaveLength(1)
|
|
100
|
+
// 7 - 1 = 6 ≤ 6 → still pending.
|
|
101
|
+
expect(queue.expireTurn('s1', 7)).toHaveLength(0)
|
|
102
|
+
// 8 - 1 = 7 > 6 → expired.
|
|
103
|
+
expect(queue.expireTurn('s1', 8)).toHaveLength(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('retires approved proposals with their receipt exactly once', () => {
|
|
107
|
+
const queue = makeQueue()
|
|
108
|
+
queue.enqueue('s1', skeleton({ id: 'p-apply-me000' }), 1)
|
|
109
|
+
queue.decide('s1', 'p-apply-me000', 'approved')
|
|
110
|
+
const settled = queue.recordReceipt('s1', 'p-apply-me000', {
|
|
111
|
+
status: 'applied',
|
|
112
|
+
estimatedTokens: 400,
|
|
113
|
+
appliedTokens: 380,
|
|
114
|
+
updatedAt: '2026-09-18T00:00:00.000Z',
|
|
115
|
+
})
|
|
116
|
+
expect(settled).toBeDefined()
|
|
117
|
+
expect(settled!.receipt.appliedTokens).toBe(380)
|
|
118
|
+
expect(settled!.items).toHaveLength(1)
|
|
119
|
+
// Retired: a second receipt for the same id misses.
|
|
120
|
+
expect(queue.recordReceipt('s1', 'p-apply-me000', {
|
|
121
|
+
status: 'applied',
|
|
122
|
+
estimatedTokens: 400,
|
|
123
|
+
updatedAt: '2026-09-18T00:00:01.000Z',
|
|
124
|
+
})).toBeUndefined()
|
|
125
|
+
expect(queue.listApproved('s1')).toHaveLength(0)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('refuses a receipt for a proposal that is not approved', () => {
|
|
129
|
+
const queue = makeQueue()
|
|
130
|
+
queue.enqueue('s1', skeleton(), 1)
|
|
131
|
+
expect(queue.recordReceipt('s1', 'proposal00001', {
|
|
132
|
+
status: 'applied',
|
|
133
|
+
estimatedTokens: 400,
|
|
134
|
+
updatedAt: '2026-09-18T00:00:00.000Z',
|
|
135
|
+
})).toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('never throws when the store seam fails (fail-open degrade)', () => {
|
|
139
|
+
// When the storageDomain seam is unavailable the wiring layer may hand the
|
|
140
|
+
// queue a store whose calls throw; the queue must degrade to no-durability
|
|
141
|
+
// behavior without ever breaking the compression pipeline.
|
|
142
|
+
const throwing: ReviewQueueStore = {
|
|
143
|
+
load: () => { throw new Error('seam unavailable') },
|
|
144
|
+
save: () => { throw new Error('seam unavailable') },
|
|
145
|
+
}
|
|
146
|
+
const queue = new ReviewQueue(throwing, { timeoutTurns: 6 })
|
|
147
|
+
expect(queue.enqueue('s1', skeleton(), 1)).toBe(true)
|
|
148
|
+
expect(queue.listPending('s1')).toEqual([])
|
|
149
|
+
expect(queue.expireTurn('s1', 3)).toEqual([])
|
|
150
|
+
expect(queue.decide('s1', 'proposal00001', 'approved')).toEqual({ ok: false, reason: 'unknown-proposal' })
|
|
151
|
+
expect(queue.recordReceipt('s1', 'proposal00001', {
|
|
152
|
+
status: 'deferred',
|
|
153
|
+
reasonCode: 'review_receipt_digest_invalid',
|
|
154
|
+
estimatedTokens: 400,
|
|
155
|
+
updatedAt: '2026-09-18T00:00:00.000Z',
|
|
156
|
+
})).toBeUndefined()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('persists across calls through the store (memory store roundtrip)', () => {
|
|
160
|
+
const shared = new MemoryReviewStore()
|
|
161
|
+
const writer = makeQueue(6, shared)
|
|
162
|
+
const reader = makeQueue(6, shared)
|
|
163
|
+
writer.enqueue('s1', skeleton(), 1)
|
|
164
|
+
expect(reader.listPending('s1')).toHaveLength(1)
|
|
165
|
+
reader.decide('s1', 'proposal00001', 'approved')
|
|
166
|
+
expect(writer.listApproved('s1')).toHaveLength(1)
|
|
167
|
+
})
|
|
168
|
+
})
|