|
1 | 1 | import type http from 'node:http'; |
| 2 | +import Database from 'better-sqlite3'; |
2 | 3 | import { randomUUID } from 'node:crypto'; |
3 | 4 | import { |
4 | 5 | chmodSync, |
@@ -27,6 +28,7 @@ import { |
27 | 28 | import { skillCwdAliasSegment } from '../src/cwd-aliases.js'; |
28 | 29 | import { getAgentDef } from '../src/agents.js'; |
29 | 30 | import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js'; |
| 31 | +import { upsertMessage } from '../src/db.js'; |
30 | 32 | import { renderCodexImagegenOverride } from '../src/prompts/system.js'; |
31 | 33 |
|
32 | 34 | const FAKE_VELA_FIXTURE = resolve(process.cwd(), 'tests', 'fixtures', 'fake-vela.mjs'); |
@@ -216,6 +218,87 @@ process.exit(0); |
216 | 218 | ); |
217 | 219 | }); |
218 | 220 |
|
| 221 | + |
| 222 | + it('reuses an existing assistant message row instead of creating a duplicate when assistantMessageId is supplied', async () => { |
| 223 | + if (!process.env.OD_DATA_DIR) { |
| 224 | + throw new Error('OD_DATA_DIR is required for assistant message reuse tests'); |
| 225 | + } |
| 226 | + const projectId = `proj-${randomUUID()}`; |
| 227 | + const assistantMessageId = `assistant-${randomUUID()}`; |
| 228 | + |
| 229 | + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { |
| 230 | + method: 'POST', |
| 231 | + headers: { 'Content-Type': 'application/json' }, |
| 232 | + body: JSON.stringify({ id: projectId, name: 'Assistant row reuse fixture' }), |
| 233 | + }); |
| 234 | + expect(createProjectResponse.ok).toBe(true); |
| 235 | + |
| 236 | + const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`); |
| 237 | + expect(conversationsResponse.ok).toBe(true); |
| 238 | + const conversationsBody = await conversationsResponse.json() as { |
| 239 | + conversations: Array<{ id: string }>; |
| 240 | + }; |
| 241 | + const conversationId = conversationsBody.conversations[0]?.id; |
| 242 | + expect(conversationId).toBeTruthy(); |
| 243 | + |
| 244 | + const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite'); |
| 245 | + const sqlite = new Database(dbFile); |
| 246 | + try { |
| 247 | + upsertMessage(sqlite as never, conversationId!, { |
| 248 | + id: assistantMessageId, |
| 249 | + role: 'assistant', |
| 250 | + content: '', |
| 251 | + runStatus: 'failed', |
| 252 | + startedAt: Date.now() - 1_000, |
| 253 | + endedAt: Date.now() - 500, |
| 254 | + }); |
| 255 | + } finally { |
| 256 | + sqlite.close(); |
| 257 | + } |
| 258 | + |
| 259 | + await withFakeAgent( |
| 260 | + 'opencode', |
| 261 | + ` |
| 262 | +process.stdin.resume(); |
| 263 | +process.stdin.on('end', () => { |
| 264 | + console.log(JSON.stringify({ type: 'step_start' })); |
| 265 | + console.log(JSON.stringify({ type: 'text', part: { text: 'reused-assistant-row-ok' } })); |
| 266 | + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); |
| 267 | + process.exit(0); |
| 268 | +}); |
| 269 | +`, |
| 270 | + async () => { |
| 271 | + const response = await fetch(`${baseUrl}/api/chat`, { |
| 272 | + method: 'POST', |
| 273 | + headers: { 'Content-Type': 'application/json' }, |
| 274 | + body: JSON.stringify({ |
| 275 | + agentId: 'opencode', |
| 276 | + projectId, |
| 277 | + conversationId, |
| 278 | + assistantMessageId, |
| 279 | + message: 'retry this turn', |
| 280 | + }), |
| 281 | + }); |
| 282 | + const body = await response.text(); |
| 283 | + expect(response.ok).toBe(true); |
| 284 | + expect(body).toContain('reused-assistant-row-ok'); |
| 285 | + }, |
| 286 | + ); |
| 287 | + |
| 288 | + const verifyDb = new Database(dbFile, { readonly: true }); |
| 289 | + try { |
| 290 | + const rows = verifyDb |
| 291 | + .prepare(`SELECT id, content, run_id FROM messages WHERE conversation_id = ? AND role = 'assistant'`) |
| 292 | + .all(conversationId) as Array<{ id: string; content: string; run_id: string | null }>; |
| 293 | + expect(rows.filter((row) => row.id === assistantMessageId)).toHaveLength(1); |
| 294 | + expect(rows.some((row) => row.id !== assistantMessageId && row.content.includes('reused-assistant-row-ok'))).toBe(false); |
| 295 | + const reused = rows.find((row) => row.id === assistantMessageId); |
| 296 | + expect(reused?.content).toContain('reused-assistant-row-ok'); |
| 297 | + } finally { |
| 298 | + verifyDb.close(); |
| 299 | + } |
| 300 | + }); |
| 301 | + |
219 | 302 | it('rewrites the OpenCode scanner overflow into a generic retry message', async () => { |
220 | 303 | const conversationId = `conv-${randomUUID()}`; |
221 | 304 |
|
@@ -311,6 +394,76 @@ child.on('exit', (code, signal) => { |
311 | 394 | } |
312 | 395 | }); |
313 | 396 |
|
| 397 | + it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => { |
| 398 | + const projectId = `proj-plugin-authoring-success-${randomUUID()}`; |
| 399 | + |
| 400 | + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { |
| 401 | + method: 'POST', |
| 402 | + headers: { 'Content-Type': 'application/json' }, |
| 403 | + body: JSON.stringify({ |
| 404 | + id: projectId, |
| 405 | + name: 'Plugin authoring artifact success fixture', |
| 406 | + skillId: null, |
| 407 | + designSystemId: null, |
| 408 | + }), |
| 409 | + }); |
| 410 | + expect(createProjectResponse.status).toBe(200); |
| 411 | + const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`); |
| 412 | + expect(conversationsResponse.status).toBe(200); |
| 413 | + const conversationsBody = await conversationsResponse.json() as { |
| 414 | + conversations: Array<{ id: string }>; |
| 415 | + }; |
| 416 | + const conversationId = conversationsBody.conversations[0]?.id; |
| 417 | + expect(conversationId).toBeTruthy(); |
| 418 | + |
| 419 | + await withFakeAgent( |
| 420 | + 'opencode', |
| 421 | + ` |
| 422 | +const fs = require('node:fs'); |
| 423 | +const path = require('node:path'); |
| 424 | +process.stdin.resume(); |
| 425 | +process.stdin.on('end', () => { |
| 426 | + const pluginDir = path.join(process.cwd(), 'generated-plugin'); |
| 427 | + fs.mkdirSync(pluginDir, { recursive: true }); |
| 428 | + fs.writeFileSync(path.join(pluginDir, 'open-design.json'), JSON.stringify({ name: 'generated-plugin' }, null, 2)); |
| 429 | + fs.writeFileSync(path.join(pluginDir, 'SKILL.md'), '# Generated plugin\\n'); |
| 430 | + console.log(JSON.stringify({ type: 'step_start' })); |
| 431 | + console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范,再生成插件文件。' } })); |
| 432 | + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); |
| 433 | + process.exit(0); |
| 434 | +}); |
| 435 | +`, |
| 436 | + async () => { |
| 437 | + const createResponse = await fetch(`${baseUrl}/api/runs`, { |
| 438 | + method: 'POST', |
| 439 | + headers: { 'Content-Type': 'application/json' }, |
| 440 | + body: JSON.stringify({ |
| 441 | + agentId: 'opencode', |
| 442 | + projectId, |
| 443 | + conversationId, |
| 444 | + pluginId: 'od-plugin-authoring', |
| 445 | + message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。', |
| 446 | + }), |
| 447 | + }); |
| 448 | + expect(createResponse.status).toBe(202); |
| 449 | + const { runId } = await createResponse.json() as { runId: string }; |
| 450 | + |
| 451 | + const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`); |
| 452 | + const eventsBody = await readSseUntil(eventsResponse, 'event: final'); |
| 453 | + const statusBody = await waitForRunStatus(baseUrl, runId); |
| 454 | + |
| 455 | + expect(eventsBody).toContain('先读取文档规范,再生成插件文件'); |
| 456 | + expect(statusBody.status).toBe('succeeded'); |
| 457 | + |
| 458 | + const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`); |
| 459 | + expect(filesResponse.status).toBe(200); |
| 460 | + const filesBody = await filesResponse.json() as { files: Array<{ name: string }> }; |
| 461 | + expect(filesBody.files.some((file) => file.name === 'generated-plugin/open-design.json')).toBe(true); |
| 462 | + expect(filesBody.files.some((file) => file.name === 'generated-plugin/SKILL.md')).toBe(true); |
| 463 | + }, |
| 464 | + ); |
| 465 | + }); |
| 466 | + |
314 | 467 | it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => { |
315 | 468 | const projectId = `proj-plugin-authoring-${randomUUID()}`; |
316 | 469 |
|
|
0 commit comments