| 1 | import fs from 'fs' |
| 2 | import os from 'os' |
| 3 | import path from 'path' |
| 4 | import { afterEach, describe, expect, it } from 'vitest' |
| 5 | import { createThinkingWorkflowTools } from '../../../src/main/thinking/thinking-tools' |
| 6 | |
| 7 | const tempDirs: string[] = [] |
| 8 | |
| 9 | const makeTempThinkingDir = async (): Promise<string> => { |
| 10 | const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'thinking-tools-')) |
| 11 | tempDirs.push(dir) |
| 12 | await fs.promises.writeFile( |
| 13 | path.join(dir, 'thinking.md'), |
| 14 | [ |
| 15 | '# Thinking Brief', |
| 16 | '', |
| 17 | '## Topic', |
| 18 | '', |
| 19 | '## Page Count', |
| 20 | '0', |
| 21 | '' |
| 22 | ].join('\n'), |
| 23 | 'utf-8' |
| 24 | ) |
| 25 | return dir |
| 26 | } |
| 27 | |
| 28 | const page = (title: string): { |
| 29 | title: string |
| 30 | role: 'content' |
| 31 | objective: string |
| 32 | summary: string |
| 33 | keyPoints: string[] |
| 34 | } => ({ |
| 35 | title, |
| 36 | role: 'content', |
| 37 | objective: `说明 ${title}`, |
| 38 | summary: `${title} 的内容摘要。`, |
| 39 | keyPoints: [`${title} 重点一`, `${title} 重点二`] |
| 40 | }) |
| 41 | |
| 42 | afterEach(async () => { |
| 43 | await Promise.all( |
| 44 | tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })) |
| 45 | ) |
| 46 | }) |
| 47 | |
| 48 | describe('thinking workflow tools', () => { |
| 49 | it('normalizes object-form confirmed decisions and preserves omitted context fields', async () => { |
| 50 | const thinkingDir = await makeTempThinkingDir() |
| 51 | const { tools, state } = createThinkingWorkflowTools({ |
| 52 | thinkingDir, |
| 53 | currentStage: 'collect' |
| 54 | }) |
| 55 | const updateContext = tools.find((tool) => tool.name === 'update_context_document') |
| 56 | expect(updateContext).toBeTruthy() |
| 57 | |
| 58 | await updateContext!.invoke({ |
| 59 | topic: '2026 AI动漫发展', |
| 60 | userIntent: '面向行业从业者进行案例分享。', |
| 61 | confirmedDecisions: [ |
| 62 | { |
| 63 | 主题: '2026年AI动漫发展', |
| 64 | 听众: '行业从业者', |
| 65 | 页数: '8页' |
| 66 | } |
| 67 | ], |
| 68 | openQuestions: ['是否需要补充更多海外案例?'] |
| 69 | }) |
| 70 | await updateContext!.invoke({ |
| 71 | latestDirection: '用户确认开始生成大纲。' |
| 72 | }) |
| 73 | |
| 74 | const context = await fs.promises.readFile(path.join(thinkingDir, 'context.md'), 'utf-8') |
| 75 | |
| 76 | expect(state.contextUpdated).toBe(true) |
| 77 | expect(state.contextUpdateCount).toBe(2) |
| 78 | expect(context).toContain('## Topic\n2026 AI动漫发展') |
| 79 | expect(context).toContain( |
| 80 | '- 主题: 2026年AI动漫发展;听众: 行业从业者;页数: 8页' |
| 81 | ) |
| 82 | expect(context).toContain('- 是否需要补充更多海外案例?') |
| 83 | expect(context).toContain('## Latest Direction\n用户确认开始生成大纲。') |
| 84 | }) |
| 85 | |
| 86 | it('clears a context list only when the model explicitly passes an empty array', async () => { |
| 87 | const thinkingDir = await makeTempThinkingDir() |
| 88 | const { tools } = createThinkingWorkflowTools({ |
| 89 | thinkingDir, |
| 90 | currentStage: 'collect' |
| 91 | }) |
| 92 | const updateContext = tools.find((tool) => tool.name === 'update_context_document') |
| 93 | expect(updateContext).toBeTruthy() |
| 94 | |
| 95 | await updateContext!.invoke({ |
| 96 | topic: '保留主题', |
| 97 | openQuestions: ['待确认问题'] |
| 98 | }) |
| 99 | await updateContext!.invoke({ |
| 100 | openQuestions: [] |
| 101 | }) |
| 102 | |
| 103 | const context = await fs.promises.readFile(path.join(thinkingDir, 'context.md'), 'utf-8') |
| 104 | expect(context).toContain('## Topic\n保留主题') |
| 105 | expect(context).not.toContain('## Open Questions') |
| 106 | expect(context).not.toContain('待确认问题') |
| 107 | }) |
| 108 | |
| 109 | it('stages page batches in memory and writes thinking.md only on final commit', async () => { |
| 110 | const thinkingDir = await makeTempThinkingDir() |
| 111 | const initial = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 112 | const { tools, state } = createThinkingWorkflowTools({ |
| 113 | thinkingDir, |
| 114 | currentStage: 'collect' |
| 115 | }) |
| 116 | const updateThinking = tools.find((tool) => tool.name === 'update_thinking_document') |
| 117 | expect(updateThinking).toBeTruthy() |
| 118 | |
| 119 | await updateThinking!.invoke({ |
| 120 | topic: '分批大纲', |
| 121 | pageCount: 4, |
| 122 | pageStart: 1, |
| 123 | pages: [page('第一页'), page('第二页')] |
| 124 | }) |
| 125 | expect(state.thinkingStaged).toBe(true) |
| 126 | expect(state.thinkingUpdated).toBe(false) |
| 127 | expect(await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8')).toBe(initial) |
| 128 | |
| 129 | await updateThinking!.invoke({ |
| 130 | pageStart: 3, |
| 131 | pages: [page('第三页'), page('第四页')], |
| 132 | commit: true |
| 133 | }) |
| 134 | const committed = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 135 | |
| 136 | expect(state.thinkingStaged).toBe(false) |
| 137 | expect(state.thinkingUpdated).toBe(true) |
| 138 | expect(state.thinkingUpdateCount).toBe(1) |
| 139 | expect(committed).toContain('## Topic\n分批大纲') |
| 140 | expect(committed).toContain('## Page Count\n4') |
| 141 | expect(committed).toContain('## Page 1: 第一页') |
| 142 | expect(committed).toContain('## Page 2: 第二页') |
| 143 | expect(committed).toContain('## Page 3: 第三页') |
| 144 | expect(committed).toContain('## Page 4: 第四页') |
| 145 | }) |
| 146 | |
| 147 | it('keeps immediate full-page replacement behavior for small outlines', async () => { |
| 148 | const thinkingDir = await makeTempThinkingDir() |
| 149 | const { tools, state } = createThinkingWorkflowTools({ |
| 150 | thinkingDir, |
| 151 | currentStage: 'collect' |
| 152 | }) |
| 153 | const updateThinking = tools.find((tool) => tool.name === 'update_thinking_document') |
| 154 | expect(updateThinking).toBeTruthy() |
| 155 | |
| 156 | await updateThinking!.invoke({ |
| 157 | topic: '小大纲', |
| 158 | pages: [page('封面'), page('结论')] |
| 159 | }) |
| 160 | const committed = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 161 | |
| 162 | expect(state.thinkingStaged).toBe(false) |
| 163 | expect(state.thinkingUpdated).toBe(true) |
| 164 | expect(committed).toContain('## Page Count\n2') |
| 165 | expect(committed).toContain('## Page 1: 封面') |
| 166 | expect(committed).toContain('## Page 2: 结论') |
| 167 | }) |
| 168 | |
| 169 | it('auto-commits a complete staged document when the model forgets commit', async () => { |
| 170 | const thinkingDir = await makeTempThinkingDir() |
| 171 | const { tools, state, finalizeStagedThinkingDocument } = createThinkingWorkflowTools({ |
| 172 | thinkingDir, |
| 173 | currentStage: 'collect' |
| 174 | }) |
| 175 | const updateThinking = tools.find((tool) => tool.name === 'update_thinking_document') |
| 176 | expect(updateThinking).toBeTruthy() |
| 177 | |
| 178 | await updateThinking!.invoke({ |
| 179 | topic: '忘记提交', |
| 180 | pageCount: 2, |
| 181 | pageStart: 1, |
| 182 | pages: [page('第一页'), page('第二页')] |
| 183 | }) |
| 184 | const result = await finalizeStagedThinkingDocument() |
| 185 | const committed = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 186 | |
| 187 | expect(result).toMatchObject({ status: 'committed', pageCount: 2 }) |
| 188 | expect(state.thinkingStaged).toBe(false) |
| 189 | expect(state.thinkingUpdated).toBe(true) |
| 190 | expect(committed).toContain('## Topic\n忘记提交') |
| 191 | expect(committed).toContain('## Page 1: 第一页') |
| 192 | expect(committed).toContain('## Page 2: 第二页') |
| 193 | }) |
| 194 | |
| 195 | it('keeps incomplete staged batches recoverable when commit is called too early', async () => { |
| 196 | const thinkingDir = await makeTempThinkingDir() |
| 197 | const initial = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 198 | const { tools, state } = createThinkingWorkflowTools({ |
| 199 | thinkingDir, |
| 200 | currentStage: 'collect' |
| 201 | }) |
| 202 | const updateThinking = tools.find((tool) => tool.name === 'update_thinking_document') |
| 203 | expect(updateThinking).toBeTruthy() |
| 204 | |
| 205 | const earlyResult = await updateThinking!.invoke({ |
| 206 | topic: '提前提交', |
| 207 | pageCount: 3, |
| 208 | pageStart: 1, |
| 209 | pages: [page('第一页')], |
| 210 | commit: true |
| 211 | }) |
| 212 | const afterEarlyCommit = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 213 | |
| 214 | expect(String(earlyResult)).toContain('thinking.md is still staged') |
| 215 | expect(String(earlyResult)).toContain('missing page 2') |
| 216 | expect(state.thinkingStaged).toBe(true) |
| 217 | expect(state.thinkingUpdated).toBe(false) |
| 218 | expect(afterEarlyCommit).toBe(initial) |
| 219 | |
| 220 | const finalResult = await updateThinking!.invoke({ |
| 221 | pageStart: 2, |
| 222 | pages: [page('第二页'), page('第三页')], |
| 223 | commit: true |
| 224 | }) |
| 225 | const committed = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 226 | |
| 227 | expect(String(finalResult)).toContain('thinking.md updated from staged batches') |
| 228 | expect(state.thinkingStaged).toBe(false) |
| 229 | expect(state.thinkingUpdated).toBe(true) |
| 230 | expect(committed).toContain('## Page 1: 第一页') |
| 231 | expect(committed).toContain('## Page 2: 第二页') |
| 232 | expect(committed).toContain('## Page 3: 第三页') |
| 233 | }) |
| 234 | |
| 235 | it('discards an incomplete staged document instead of writing a partial thinking.md', async () => { |
| 236 | const thinkingDir = await makeTempThinkingDir() |
| 237 | const initial = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 238 | const { tools, state, finalizeStagedThinkingDocument } = createThinkingWorkflowTools({ |
| 239 | thinkingDir, |
| 240 | currentStage: 'collect' |
| 241 | }) |
| 242 | const updateThinking = tools.find((tool) => tool.name === 'update_thinking_document') |
| 243 | expect(updateThinking).toBeTruthy() |
| 244 | |
| 245 | await updateThinking!.invoke({ |
| 246 | topic: '半截大纲', |
| 247 | pageCount: 3, |
| 248 | pageStart: 1, |
| 249 | pages: [page('第一页'), page('第二页')] |
| 250 | }) |
| 251 | const result = await finalizeStagedThinkingDocument() |
| 252 | const after = await fs.promises.readFile(path.join(thinkingDir, 'thinking.md'), 'utf-8') |
| 253 | |
| 254 | expect(result).toMatchObject({ |
| 255 | status: 'discarded', |
| 256 | reason: 'missing page 3', |
| 257 | pageCount: 2, |
| 258 | expectedPageCount: 3 |
| 259 | }) |
| 260 | expect(state.thinkingStaged).toBe(false) |
| 261 | expect(state.thinkingUpdated).toBe(false) |
| 262 | expect(after).toBe(initial) |
| 263 | }) |
| 264 | }) |
| 265 |