Skip to content

Commit 1245157

Browse files
universe-hcyclaude
andcommitted
feat: 添加 push/pop 上下文栈(讨论分支)
/push 开一个继承完整上下文的讨论旁支,/pop 把讨论蒸馏成结构化 digest 回卷主线——讨论噪音不污染主线、只留结论。支持嵌套(≤3)、--to #N 跨层弹出、 --list 零成本列栈,以及栈感知 auto-compact 三选确认(压到最近/指定 push 点/全量)。 纯增量、opt-in,feature flag PUSH_POP。 Co-Authored-By: claude-opus-4-8[1m] <noreply@anthropic.com>
1 parent 34b3dc9 commit 1245157

18 files changed

Lines changed: 1281 additions & 97 deletions

File tree

scripts/defines.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,8 @@ export const DEFAULT_BUILD_FEATURES = [
9898
// Persistent thread goal command — auto-continuation, JSONL persistence,
9999
// strict completion/blocked audit. See src/services/goal.
100100
'GOAL',
101+
// Push/Pop context stack — /push opens a discussion branch inheriting full
102+
// context, /pop distills it to a digest and rewinds the mainline. Opt-in,
103+
// zero code path when unused. See docs/features/push-pop-context-stack.md.
104+
'PUSH_POP',
101105
] as const

src/Tool.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import type {
1010
import type { UUID } from 'crypto'
1111
import type { z } from 'zod/v4'
1212
import type { Command } from './commands.js'
13+
import type {
14+
PopOptions,
15+
CompactStrategyMarker,
16+
CompactStrategyChoice,
17+
} from './services/pushStack/state.js'
1318
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
1419
import type { ThinkingConfig } from './utils/thinking.js'
1520

@@ -239,6 +244,21 @@ export type ToolUseContext = {
239244
onCompactProgress?: (event: CompactProgressEvent) => void
240245
setSDKStatus?: (status: SDKStatus) => void
241246
openMessageSelector?: () => void
247+
/** Push/pop context stack (docs/features/push-pop-context-stack.md). Only
248+
* wired in interactive REPL contexts; the heavy apply logic lives there. */
249+
pushContextMark?: (note: string) => void
250+
applyPop?: (opts: PopOptions) => void
251+
/** After a push-stack-aware auto-compact, retain only markers at or after
252+
* the given marker id. Called by autoCompactIfNeeded to sync the REPL's
253+
* pushStack when older markers are consumed by the compaction. */
254+
retainPushMarkersFrom?: (fromMarkerId: string) => void
255+
/** Interactive three-way auto-compact strategy picker. When wired (REPL),
256+
* the query loop awaits this before deciding full vs. partial compaction.
257+
* Not wired in non-interactive / forked-agent contexts. */
258+
askCompactStrategy?: (opts: {
259+
markers: ReadonlyArray<CompactStrategyMarker>
260+
signal: AbortSignal
261+
}) => Promise<CompactStrategyChoice>
242262
updateFileHistoryState: (
243263
updater: (prev: FileHistoryState) => FileHistoryState,
244264
) => void

src/commands.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ const goalCmd = feature('GOAL')
168168
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
169169
).default
170170
: null
171+
const pushCmd = feature('PUSH_POP')
172+
? (
173+
require('./commands/push/index.js') as typeof import('./commands/push/index.js')
174+
).default
175+
: null
176+
const popCmd = feature('PUSH_POP')
177+
? (
178+
require('./commands/pop/index.js') as typeof import('./commands/pop/index.js')
179+
).default
180+
: null
171181
/* eslint-enable @typescript-eslint/no-require-imports */
172182
import thinkback from './commands/thinkback/index.js'
173183
import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -372,6 +382,8 @@ const COMMANDS = memoize((): Command[] => [
372382
...(buddy ? [buddy] : []),
373383
...(poor ? [poor] : []),
374384
...(goalCmd ? [goalCmd] : []),
385+
...(pushCmd ? [pushCmd] : []),
386+
...(popCmd ? [popCmd] : []),
375387
...(proactive ? [proactive] : []),
376388
...(monitorCmd ? [monitorCmd] : []),
377389
...(coordinatorCmd ? [coordinatorCmd] : []),
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { describe, expect, mock, test, beforeAll } from 'bun:test'
2+
import { logMock } from '../../../tests/mocks/log.js'
3+
import { debugMock } from '../../../tests/mocks/debug.js'
4+
5+
// Mock bun:bundle before anything that calls feature()
6+
mock.module('bun:bundle', () => ({ feature: (_name: string) => false }))
7+
8+
// Cut bootstrap/state.ts side-effect chain
9+
mock.module('src/utils/log.ts', logMock)
10+
mock.module('src/utils/debug.ts', debugMock)
11+
mock.module('src/utils/config.ts', () => ({
12+
getGlobalConfig: () => ({}),
13+
saveGlobalConfig: async () => {},
14+
getGlobalConfigWriteCount: () => 0,
15+
}))
16+
mock.module('src/utils/settings/settings.js', () => ({
17+
getCachedOrLoadSettings: async () => ({}),
18+
getCachedSettings: () => ({}),
19+
}))
20+
21+
let parsePopArgs: (
22+
args: string,
23+
) => import('../../services/pushStack/state.js').PopOptions | string
24+
25+
beforeAll(async () => {
26+
const popModule = await import('../pop/pop.js')
27+
parsePopArgs = popModule.parsePopArgs
28+
})
29+
30+
describe('parsePopArgs', () => {
31+
test('empty args yields empty options', () => {
32+
const result = parsePopArgs('')
33+
expect(typeof result).toBe('object')
34+
expect(result).toEqual({})
35+
})
36+
37+
test('--discard sets discard flag', () => {
38+
const result = parsePopArgs('--discard')
39+
expect(result).toEqual({ discard: true })
40+
})
41+
42+
test('--keep-code sets keepCode flag', () => {
43+
const result = parsePopArgs('--keep-code')
44+
expect(result).toEqual({ keepCode: true })
45+
})
46+
47+
test('--to #N parses marker number', () => {
48+
const result = parsePopArgs('--to #2')
49+
expect(result).toEqual({ to: 2 })
50+
})
51+
52+
test('--to N (without #) parses marker number', () => {
53+
const result = parsePopArgs('--to 3')
54+
expect(result).toEqual({ to: 3 })
55+
})
56+
57+
test('--to=N= syntax works', () => {
58+
const result = parsePopArgs('--to=1')
59+
expect(result).toEqual({ to: 1 })
60+
})
61+
62+
test('combined flags work', () => {
63+
const result = parsePopArgs('--to #1 --keep-code')
64+
expect(result).toEqual({ to: 1, keepCode: true })
65+
})
66+
67+
test('--to missing number returns error string', () => {
68+
const result = parsePopArgs('--to')
69+
expect(typeof result).toBe('string')
70+
expect(result as string).toMatch(/Missing marker/)
71+
})
72+
73+
test('--to 0 returns error (must be >= 1)', () => {
74+
const result = parsePopArgs('--to 0')
75+
expect(typeof result).toBe('string')
76+
})
77+
78+
test('--to #abc returns error', () => {
79+
const result = parsePopArgs('--to #abc')
80+
expect(typeof result).toBe('string')
81+
expect(result as string).toMatch(/Invalid marker/)
82+
})
83+
84+
test('unknown flag returns error string', () => {
85+
const result = parsePopArgs('--unknown')
86+
expect(typeof result).toBe('string')
87+
expect(result as string).toMatch(/Unknown option/)
88+
})
89+
})
90+
91+
describe('push stack state', () => {
92+
test('MAX_PUSH_DEPTH is 3', async () => {
93+
const { MAX_PUSH_DEPTH } = await import('../../services/pushStack/state.js')
94+
expect(MAX_PUSH_DEPTH).toBe(3)
95+
})
96+
97+
test('getPushStackMirror starts empty', async () => {
98+
const { getPushStackMirror } = await import(
99+
'../../services/pushStack/state.js'
100+
)
101+
const mirror = getPushStackMirror()
102+
expect(Array.isArray(mirror)).toBe(true)
103+
// May have stale content if other tests ran first — just verify it's readable
104+
expect(mirror).toBeDefined()
105+
})
106+
107+
test('setPushStackMirror round-trips', async () => {
108+
const { getPushStackMirror, setPushStackMirror } = await import(
109+
'../../services/pushStack/state.js'
110+
)
111+
const marker = {
112+
id: 'test-id',
113+
messageUuid:
114+
'00000000-0000-0000-0000-000000000001' as `${string}-${string}-${string}-${string}-${string}`,
115+
note: 'test note',
116+
timestamp: Date.now(),
117+
anchorPreview: 'preview text',
118+
}
119+
setPushStackMirror([marker])
120+
const stack = getPushStackMirror()
121+
expect(stack.length).toBe(1)
122+
expect(stack[0]!.id).toBe('test-id')
123+
expect(stack[0]!.note).toBe('test note')
124+
// Restore
125+
setPushStackMirror([])
126+
})
127+
})
128+
129+
describe('DIGEST_PROMPT and DIGEST_TEMPLATE', () => {
130+
test('DIGEST_PROMPT contains required four-section structure', async () => {
131+
const { DIGEST_PROMPT } = await import(
132+
'../../services/pushStack/digestPrompt.js'
133+
)
134+
expect(DIGEST_PROMPT).toContain('[Decisions]')
135+
expect(DIGEST_PROMPT).toContain('[Rejected]')
136+
expect(DIGEST_PROMPT).toContain('[Open questions]')
137+
expect(DIGEST_PROMPT).toContain('[Action items]')
138+
expect(DIGEST_PROMPT).toContain('<analysis>')
139+
expect(DIGEST_PROMPT).toContain('<summary>')
140+
})
141+
142+
test('DIGEST_TEMPLATE is a non-empty string', async () => {
143+
const { DIGEST_TEMPLATE } = await import(
144+
'../../services/pushStack/digestPrompt.js'
145+
)
146+
expect(typeof DIGEST_TEMPLATE).toBe('string')
147+
expect(DIGEST_TEMPLATE.length).toBeGreaterThan(0)
148+
})
149+
})
150+
151+
describe('pop command integration', () => {
152+
test('empty stack returns error text', async () => {
153+
const { call } = await import('../pop/pop.js')
154+
const ctx = {
155+
getAppState: () => ({ pushStack: [] as unknown[] }),
156+
applyPop: undefined,
157+
} as unknown as import('../../Tool.js').ToolUseContext
158+
const result = await call('', ctx)
159+
expect(result.type).toBe('text')
160+
expect((result as { type: string; value: string }).value).toMatch(
161+
/No push point to pop/,
162+
)
163+
})
164+
165+
test('stack overflow --to N returns error text', async () => {
166+
const { call } = await import('../pop/pop.js')
167+
const ctx = {
168+
getAppState: () => ({
169+
pushStack: [
170+
{
171+
id: 'a',
172+
messageUuid: '00000000-0000-0000-0000-000000000001',
173+
note: '',
174+
timestamp: 0,
175+
anchorPreview: '',
176+
},
177+
],
178+
}),
179+
applyPop: undefined,
180+
} as unknown as import('../../Tool.js').ToolUseContext
181+
const result = await call('--to #5', ctx)
182+
expect(result.type).toBe('text')
183+
expect((result as { type: string; value: string }).value).toMatch(
184+
/No push point #5/,
185+
)
186+
})
187+
188+
test('interactive session with no applyPop returns error text', async () => {
189+
const { call } = await import('../pop/pop.js')
190+
const ctx = {
191+
getAppState: () => ({
192+
pushStack: [
193+
{
194+
id: 'a',
195+
messageUuid: '00000000-0000-0000-0000-000000000001',
196+
note: '',
197+
timestamp: 0,
198+
anchorPreview: '',
199+
},
200+
],
201+
}),
202+
applyPop: undefined,
203+
} as unknown as import('../../Tool.js').ToolUseContext
204+
const result = await call('', ctx)
205+
expect(result.type).toBe('text')
206+
expect((result as { type: string; value: string }).value).toMatch(
207+
/interactive session/,
208+
)
209+
})
210+
211+
test('invalid args returns error text', async () => {
212+
const { call } = await import('../pop/pop.js')
213+
const ctx = {
214+
getAppState: () => ({ pushStack: [] as unknown[] }),
215+
} as unknown as import('../../Tool.js').ToolUseContext
216+
const result = await call('--unknown-flag', ctx)
217+
expect(result.type).toBe('text')
218+
expect((result as { type: string; value: string }).value).toMatch(
219+
/Unknown option/,
220+
)
221+
})
222+
})
223+
224+
describe('push command integration', () => {
225+
test('--list on empty stack returns empty message', async () => {
226+
const { call } = await import('../push/push.js')
227+
const ctx = {
228+
getAppState: () => ({ pushStack: [] }),
229+
pushContextMark: undefined,
230+
} as unknown as import('../../Tool.js').ToolUseContext
231+
const result = await call('--list', ctx)
232+
expect(result.type).toBe('text')
233+
expect((result as { type: string; value: string }).value).toMatch(
234+
/No active push points/,
235+
)
236+
})
237+
238+
test('--list with items renders stack', async () => {
239+
const { call } = await import('../push/push.js')
240+
const now = Date.now()
241+
const ctx = {
242+
getAppState: () => ({
243+
pushStack: [
244+
{
245+
id: 'a',
246+
messageUuid: '00000000-0000-0000-0000-000000000001',
247+
note: 'discuss API',
248+
timestamp: now - 60000,
249+
anchorPreview: 'let me think...',
250+
},
251+
{
252+
id: 'b',
253+
messageUuid: '00000000-0000-0000-0000-000000000002',
254+
note: '',
255+
timestamp: now - 30000,
256+
anchorPreview: 'another point',
257+
},
258+
],
259+
}),
260+
pushContextMark: undefined,
261+
} as unknown as import('../../Tool.js').ToolUseContext
262+
const result = await call('--list', ctx)
263+
expect(result.type).toBe('text')
264+
const text = (result as { type: string; value: string }).value
265+
expect(text).toContain('Push stack (2)')
266+
expect(text).toContain('#1')
267+
expect(text).toContain('#2')
268+
expect(text).toContain('discuss API')
269+
})
270+
271+
test('no pushContextMark returns not-interactive error', async () => {
272+
const { call } = await import('../push/push.js')
273+
const ctx = {
274+
getAppState: () => ({ pushStack: [] }),
275+
pushContextMark: undefined,
276+
} as unknown as import('../../Tool.js').ToolUseContext
277+
const result = await call('my note', ctx)
278+
expect(result.type).toBe('text')
279+
expect((result as { type: string; value: string }).value).toMatch(
280+
/interactive session/,
281+
)
282+
})
283+
284+
test('with pushContextMark invokes it and returns skip', async () => {
285+
const { call } = await import('../push/push.js')
286+
let capturedNote = ''
287+
const ctx = {
288+
getAppState: () => ({ pushStack: [] }),
289+
pushContextMark: (note: string) => {
290+
capturedNote = note
291+
},
292+
} as unknown as import('../../Tool.js').ToolUseContext
293+
const result = await call('test branch note', ctx)
294+
expect(result.type).toBe('skip')
295+
expect(capturedNote).toBe('test branch note')
296+
})
297+
})

src/commands/pop/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Command } from '../../commands.js'
2+
3+
const pop = {
4+
description:
5+
'Close the current discussion branch: distill it into a digest and rewind to the push point',
6+
name: 'pop',
7+
argumentHint: '[--to #N] [--discard] [--keep-code]',
8+
type: 'local',
9+
supportsNonInteractive: false,
10+
load: () => import('./pop.js'),
11+
} satisfies Command
12+
13+
export default pop

0 commit comments

Comments
 (0)