@mobius-os/mobius 0.3.26 → 0.3.31
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/install.ps1 +265 -0
- package/package.json +23 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +6 -0
- package/src/components/Chat.tsx +125 -189
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +123 -5
- package/src/lib/screen-text.ts +73 -27
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/tests/aimux.test.tsx +210 -0
- package/tests/flow.test.tsx +211 -0
- package/tests/integration.test.ts +114 -0
- package/tests/preview.tsx +104 -0
- package/tests/reconnect.test.tsx +170 -0
- package/tests/resume.test.tsx +73 -0
- package/tests/screen.test.tsx +117 -0
- package/tests/scroll.test.tsx +252 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +889 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow test — drive the WHOLE App as a user would, end to end, through the real
|
|
3
|
+
* Ink screens (login → prep wizard → chat → /clear → /resume), against a mocked
|
|
4
|
+
* backend. Captures rendered frames at each milestone as evidence.
|
|
5
|
+
*
|
|
6
|
+
* Run: npm run test:flow
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
|
|
12
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-flow-'))
|
|
13
|
+
process.env.MOBIUS_TUI_HOME = TMP_HOME
|
|
14
|
+
|
|
15
|
+
import React from 'react'
|
|
16
|
+
import { render } from 'ink-testing-library'
|
|
17
|
+
import { App } from '../src/App.js'
|
|
18
|
+
|
|
19
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
20
|
+
const RS: any = (globalThis as any).ReadableStream
|
|
21
|
+
const enc = new TextEncoder()
|
|
22
|
+
let sseController: any = null
|
|
23
|
+
function emit(ev: string, data: Record<string, unknown>) {
|
|
24
|
+
sseController?.enqueue(enc.encode(`event: ${ev}\ndata: ${JSON.stringify({ event: ev, ...data })}\n\n`))
|
|
25
|
+
}
|
|
26
|
+
function json(body: unknown, status = 200) {
|
|
27
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const snapshots: { step: string; frame: string }[] = []
|
|
31
|
+
function snap(step: string, frame: string) { snapshots.push({ step, frame: frame.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') }) }
|
|
32
|
+
|
|
33
|
+
let pass = 0, fail = 0
|
|
34
|
+
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
35
|
+
|
|
36
|
+
// ── mocked backend (precise URL matchers — substring overlaps broke an earlier draft) ─
|
|
37
|
+
const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
|
|
38
|
+
function mockFetch(url: string, init?: RequestInit): Response {
|
|
39
|
+
// SSE
|
|
40
|
+
if (url.includes('/events')) {
|
|
41
|
+
return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n')) } }),
|
|
42
|
+
{ status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
43
|
+
}
|
|
44
|
+
const method = init?.method ?? 'GET'
|
|
45
|
+
// auth
|
|
46
|
+
if (url.endsWith('/api/auth/config')) return json({ password_required: false })
|
|
47
|
+
if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
|
|
48
|
+
if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
|
|
49
|
+
if (url.includes('/aimux_bridge/api/remotes/') && url.endsWith('/connection')) {
|
|
50
|
+
const match = url.match(/remotes\/([^/]+)\/connection/)
|
|
51
|
+
return json({ identifier: match ? decodeURIComponent(match[1]) : 'tui-test', event_stream_connected: true })
|
|
52
|
+
}
|
|
53
|
+
// sessions (must be checked before issues/projects — the session URL contains /issues too)
|
|
54
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID }) // create session
|
|
55
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') { // list sessions (resume)
|
|
56
|
+
return json([{ session_id: SID, name: '历史会话一', last_active: new Date(Date.now() - 3600_000).toISOString(), message_count: 5, model: 'codex', issue_title: '命令行任务' }])
|
|
57
|
+
}
|
|
58
|
+
if (url.endsWith('/messages') && method === 'POST') {
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
emit('typing', { active: true })
|
|
61
|
+
emit('jsonl_entry', { session_id: SID, entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '已收到,这是来自 TUI 的回复。' }] } } })
|
|
62
|
+
emit('typing', { active: false })
|
|
63
|
+
}, 200)
|
|
64
|
+
return json({ ok: true, session_id: SID, turn_number: 1 })
|
|
65
|
+
}
|
|
66
|
+
if (url.endsWith(`/api/sessions/${SID}/status`)) {
|
|
67
|
+
return json({ session_id: SID, alive: true, working: false })
|
|
68
|
+
}
|
|
69
|
+
// issues
|
|
70
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
|
|
71
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务' }]) // list issues
|
|
72
|
+
// projects
|
|
73
|
+
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) // list projects
|
|
74
|
+
if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
|
|
75
|
+
// preference lookups
|
|
76
|
+
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
|
|
77
|
+
if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
|
|
78
|
+
if (url.includes('/skills')) return json([])
|
|
79
|
+
if (url.includes('/memories')) return json([])
|
|
80
|
+
return json({ error: `unmocked ${method} ${url}` }, 404)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 4000) {
|
|
84
|
+
for (let i = 0; i < timeoutMs / 50; i++) {
|
|
85
|
+
if ((lastFrame() ?? '').includes(needle)) return true
|
|
86
|
+
await delay(50)
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
// Pre-seed login so App auto-logs in (login form itself is covered in ui.test).
|
|
93
|
+
fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
|
|
94
|
+
server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
|
|
95
|
+
user: { id: 'tester', display_name: 'Test User', role: 'admin' },
|
|
96
|
+
}))
|
|
97
|
+
const realFetch = globalThis.fetch
|
|
98
|
+
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
99
|
+
process.env.MOBIUS_TUI_DEBUG = '1'
|
|
100
|
+
|
|
101
|
+
console.log('\n[FLOW] full App drive (mocked backend)\n')
|
|
102
|
+
const { stdin, lastFrame, unmount } = render(React.createElement(App))
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// ── prep: project picker (auto-logged in) ────────────────────────────────
|
|
106
|
+
ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
|
|
107
|
+
// pick "➕ 创建新项目" (active index 0) → name wizard
|
|
108
|
+
stdin.write('\r'); await delay(120)
|
|
109
|
+
ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
|
|
110
|
+
stdin.write('测试项目PTY'); await delay(120)
|
|
111
|
+
stdin.write('\r'); await delay(300) // submit project name
|
|
112
|
+
snap('1-prep-project-created', lastFrame() ?? '')
|
|
113
|
+
|
|
114
|
+
// ── prep: issue picker (no issues → create) ──────────────────────────────
|
|
115
|
+
ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
|
|
116
|
+
stdin.write('\r'); await delay(120) // → create-name
|
|
117
|
+
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
|
|
118
|
+
stdin.write('\x1b'); await delay(120) // Esc → issue list
|
|
119
|
+
ok(await waitFor(lastFrame, '选择任务(Issue)'), 'Esc returns from issue name wizard to issue list')
|
|
120
|
+
stdin.write('\r'); await delay(120) // → create-name again
|
|
121
|
+
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard can be reopened after Esc')
|
|
122
|
+
stdin.write('命令行任务'); await delay(120)
|
|
123
|
+
stdin.write('\r'); await delay(300) // create issue (worktree off) → model
|
|
124
|
+
|
|
125
|
+
// ── prep: preferences ────────────────────────────────────────────────────
|
|
126
|
+
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
|
|
127
|
+
stdin.write('\r'); await delay(250) // pick codex
|
|
128
|
+
ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
|
|
129
|
+
stdin.write('\r'); await delay(400) // zh; skills+memories empty → auto-skip
|
|
130
|
+
|
|
131
|
+
// ── chat ─────────────────────────────────────────────────────────────────
|
|
132
|
+
ok(await waitFor(lastFrame, '输入问题'), 'entered chat (preferences complete)')
|
|
133
|
+
snap('2-chat-ready', lastFrame() ?? '')
|
|
134
|
+
|
|
135
|
+
// send a message — expect streamed assistant reply
|
|
136
|
+
stdin.write('你好,请回复一句话'); await delay(120)
|
|
137
|
+
stdin.write('\r')
|
|
138
|
+
ok(await waitFor(lastFrame, '已收到', 6000), 'assistant reply streamed into transcript')
|
|
139
|
+
await delay(300)
|
|
140
|
+
snap('3-chat-after-reply', lastFrame() ?? '')
|
|
141
|
+
|
|
142
|
+
// ── /clear ───────────────────────────────────────────────────────────────
|
|
143
|
+
stdin.write('/clear'); await delay(120)
|
|
144
|
+
stdin.write('\r')
|
|
145
|
+
ok(await waitFor(lastFrame, '输入问题'), '/clear reset to a fresh chat')
|
|
146
|
+
snap('4-after-clear', lastFrame() ?? '')
|
|
147
|
+
|
|
148
|
+
// ── /resume ──────────────────────────────────────────────────────────────
|
|
149
|
+
// /resume — wait for the post-/clear remount to settle, then type slowly.
|
|
150
|
+
await delay(500)
|
|
151
|
+
stdin.write('/resume'); await delay(300)
|
|
152
|
+
stdin.write('\r')
|
|
153
|
+
await delay(500)
|
|
154
|
+
snap('4b-resume-picker', lastFrame() ?? '')
|
|
155
|
+
ok(await waitFor(lastFrame, '恢复历史会话'), '/resume picker opened')
|
|
156
|
+
ok((lastFrame() ?? '').includes('历史会话一'), 'resume list shows the past session')
|
|
157
|
+
stdin.write('\r'); await delay(500) // pick session → reconnect SSE
|
|
158
|
+
ok(await waitFor(lastFrame, '输入问题'), 'resumed into chat')
|
|
159
|
+
snap('5-after-resume', lastFrame() ?? '')
|
|
160
|
+
|
|
161
|
+
// ── /config: full reconfigure (project → issue → model) ──────────────
|
|
162
|
+
// Unlike /model, /config walks through project, issue, AND model steps.
|
|
163
|
+
await delay(400)
|
|
164
|
+
stdin.write('/config'); await delay(300)
|
|
165
|
+
stdin.write('\r')
|
|
166
|
+
ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
|
|
167
|
+
ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
|
|
168
|
+
// Pick the first project (created above).
|
|
169
|
+
stdin.write('\r'); await delay(400)
|
|
170
|
+
ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
|
|
171
|
+
// Pick the first issue.
|
|
172
|
+
stdin.write('\r'); await delay(400)
|
|
173
|
+
ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
|
|
174
|
+
ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
|
|
175
|
+
stdin.write('\r'); await delay(700) // pick codex → create session
|
|
176
|
+
ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
|
|
177
|
+
ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
|
|
178
|
+
snap('6-after-config', lastFrame() ?? '')
|
|
179
|
+
|
|
180
|
+
// ── /model: swap model only, keep current task ─────────────────────────
|
|
181
|
+
// No issue/project step — the current task is kept; pick a model and App
|
|
182
|
+
// remounts Chat on the eagerly created session.
|
|
183
|
+
await delay(400)
|
|
184
|
+
stdin.write('/model'); await delay(300)
|
|
185
|
+
stdin.write('\r')
|
|
186
|
+
ok(await waitFor(lastFrame, '更换模型'), '/model opens the model picker directly')
|
|
187
|
+
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown without an issue-selection step')
|
|
188
|
+
ok((lastFrame() ?? '').includes('当前任务: 命令行任务'), 'current task is kept (issue not changed)')
|
|
189
|
+
ok(await waitFor(lastFrame, 'GPT-5.5'), 'model list rendered (Select mounted)')
|
|
190
|
+
stdin.write('\r'); await delay(700) // pick codex → create session
|
|
191
|
+
ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
|
|
192
|
+
ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
|
|
193
|
+
snap('7-after-model', lastFrame() ?? '')
|
|
194
|
+
snap('6-after-config', lastFrame() ?? '')
|
|
195
|
+
} finally {
|
|
196
|
+
unmount()
|
|
197
|
+
globalThis.fetch = realFetch
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log('\n──────── captured frames ────────')
|
|
201
|
+
for (const s of snapshots) {
|
|
202
|
+
console.log(`\n── ${s.step} ──`)
|
|
203
|
+
console.log(s.frame.replace(/\n{3,}/g, '\n\n').trim())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
207
|
+
console.log(`\n==== FLOW RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
208
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
main().catch((e) => { console.error('FATAL', e); process.exit(2) })
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — exercises the real Mobius backend end-to-end.
|
|
3
|
+
*
|
|
4
|
+
* login (passwordless) → getMe → listModels → createProject → createIssue →
|
|
5
|
+
* createSession → sendMessage → open SSE → assert assistant entries stream in.
|
|
6
|
+
*
|
|
7
|
+
* Run: npm run test:integration
|
|
8
|
+
* Target: a local Mobius backend by default; set MOBIUS_TUI_SERVER /
|
|
9
|
+
* MOBIUS_TUI_USER to point at a specific server.
|
|
10
|
+
*/
|
|
11
|
+
import { login, getMe, MobiusClient, ApiError } from '../src/api.js'
|
|
12
|
+
import { SseConnection } from '../src/sse.js'
|
|
13
|
+
import { assistantEntryText, isHiddenNoise } from '../src/lib/entry-view.js'
|
|
14
|
+
import type { AnyEntry } from '../src/types.js'
|
|
15
|
+
|
|
16
|
+
const SERVER = process.env.MOBIUS_TUI_SERVER || 'http://127.0.0.1:45616'
|
|
17
|
+
const USERNAME = process.env.MOBIUS_TUI_USER || 'admin'
|
|
18
|
+
const WAIT_MS = Number(process.env.MOBIUS_TUI_WAIT_MS || 90000)
|
|
19
|
+
|
|
20
|
+
let pass = 0, fail = 0
|
|
21
|
+
function ok(cond: boolean, msg: string) {
|
|
22
|
+
if (cond) { pass++; console.log(` ✓ ${msg}`) }
|
|
23
|
+
else { fail++; console.error(` ✗ ${msg}`) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
console.log(`\n[1/7] login → ${SERVER} as ${USERNAME}`)
|
|
28
|
+
const lr = await login(SERVER, USERNAME)
|
|
29
|
+
ok(!!lr.token && lr.token.length > 20, `got token (len ${lr.token.length})`)
|
|
30
|
+
ok(lr.user.id === USERNAME, `user.id = ${lr.user.id} (${lr.user.display_name})`)
|
|
31
|
+
|
|
32
|
+
console.log('\n[2/7] getMe validates token')
|
|
33
|
+
const me = await getMe(SERVER, lr.token)
|
|
34
|
+
ok(me.id === USERNAME, `getMe ok, role=${me.role}, work_dir=${me.work_dir}`)
|
|
35
|
+
|
|
36
|
+
const client = new MobiusClient(SERVER, lr.token)
|
|
37
|
+
|
|
38
|
+
console.log('\n[3/7] model options')
|
|
39
|
+
const models = await client.modelOptions()
|
|
40
|
+
const keys = models.map(m => m.key)
|
|
41
|
+
ok(models.length > 0, `${models.length} models available`)
|
|
42
|
+
ok(keys.includes('codex') || models.some(m => /codex|claude|deepseek/i.test(m.key)),
|
|
43
|
+
`a usable model present (sample: ${keys.slice(0, 5).join(', ')})`)
|
|
44
|
+
const modelKey = keys.includes('codex') ? 'codex' : models[0].key
|
|
45
|
+
|
|
46
|
+
console.log('\n[4/7] create test project + issue')
|
|
47
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
48
|
+
const proj = await client.createProject({ name: `tui-itest-${stamp}`, description: 'TUI integration test (auto)', bindPath: `tui-itest-${stamp}`, defaultUseWorktree: false })
|
|
49
|
+
ok(!!proj.id, `project created: ${proj.name} (${proj.id})`)
|
|
50
|
+
const issue = await client.createIssue(proj.id, { title: 'auto-test', description: 'integration', use_worktree: false })
|
|
51
|
+
ok(!!issue.id, `issue created: ${issue.title} (${issue.id})`)
|
|
52
|
+
|
|
53
|
+
console.log('\n[5/7] create session with preferences')
|
|
54
|
+
const session = await client.createSession(issue.id, {
|
|
55
|
+
name: `tui-itest-${stamp}`, model: modelKey, language: 'zh',
|
|
56
|
+
excluded_skill_ids: [], excluded_memory_ids: [],
|
|
57
|
+
})
|
|
58
|
+
ok(!!session.session_id, `session created (${session.session_id})`)
|
|
59
|
+
|
|
60
|
+
console.log('\n[6/7] send message + open SSE, wait for assistant reply')
|
|
61
|
+
await client.sendMessage(session.session_id, '请只回复两个字:成功。不要调用任何工具。')
|
|
62
|
+
|
|
63
|
+
const entries: AnyEntry[] = []
|
|
64
|
+
let gotAssistant = false
|
|
65
|
+
let firstEntryMs = 0
|
|
66
|
+
const t0 = Date.now()
|
|
67
|
+
await new Promise<void>((resolve) => {
|
|
68
|
+
const url = `${SERVER}/api/sessions/${encodeURIComponent(session.session_id)}/events?token=${encodeURIComponent(lr.token)}`
|
|
69
|
+
const conn = new SseConnection(url, {
|
|
70
|
+
onEntry: (entry) => {
|
|
71
|
+
if (!firstEntryMs) firstEntryMs = Date.now() - t0
|
|
72
|
+
entries.push(entry)
|
|
73
|
+
if (!isHiddenNoise(entry) && assistantEntryText(entry)) gotAssistant = true
|
|
74
|
+
if (gotAssistant) { setTimeout(resolve, 1500) } // grab trailing entries
|
|
75
|
+
},
|
|
76
|
+
onError: (m) => console.error(' (sse error)', m),
|
|
77
|
+
})
|
|
78
|
+
conn.start()
|
|
79
|
+
const deadline = setTimeout(() => { conn.close(); resolve() }, WAIT_MS)
|
|
80
|
+
// also resolve promptly once we clearly have a finalized assistant msg
|
|
81
|
+
const poll = setInterval(() => {
|
|
82
|
+
if (gotAssistant && Date.now() - (entries[entries.length - 1]?.__ts ?? 0) > 3000) {
|
|
83
|
+
// no-op; rely on the 1.5s resolve above
|
|
84
|
+
}
|
|
85
|
+
if (Date.now() - t0 > WAIT_MS) { clearInterval(poll); clearTimeout(deadline) }
|
|
86
|
+
}, 1000)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
ok(entries.length > 0, `received ${entries.length} SSE jsonl_entry events`)
|
|
90
|
+
ok(firstEntryMs > 0, `first entry after ${firstEntryMs}ms`)
|
|
91
|
+
ok(gotAssistant, 'received an assistant text entry')
|
|
92
|
+
|
|
93
|
+
// Show a sample of the transcript the TUI would render.
|
|
94
|
+
console.log('\n —— transcript sample ——')
|
|
95
|
+
for (const e of entries.slice(0, 8)) {
|
|
96
|
+
const t = assistantEntryText(e)
|
|
97
|
+
if (t) console.log(' • ' + t.slice(0, 120).replace(/\n/g, '\n '))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('\n[7/7] cleanup')
|
|
101
|
+
try { await client.stopSession(session.session_id) } catch { /* ignore */ }
|
|
102
|
+
for (const p of [`/api/sessions/${session.session_id}`, `/api/issues/${issue.id}`, `/api/projects/${proj.id}`]) {
|
|
103
|
+
try { await fetch(`${SERVER}${p}`, { method: 'DELETE', headers: { Authorization: `Bearer ${lr.token}` } }) } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
console.log(' (best-effort cleanup done)')
|
|
106
|
+
|
|
107
|
+
console.log(`\n==== RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
108
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch((e) => {
|
|
112
|
+
console.error('\nFATAL:', e instanceof ApiError ? `${e.message} (HTTP ${e.status})` : e)
|
|
113
|
+
process.exit(2)
|
|
114
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性预览: 把典型 jsonl entry 喂进新的 mergeToolCalls → viewsForBlock,
|
|
3
|
+
* 按 Chat.tsx 的 ViewLine 格式打印, 直观验证"对齐 web"后的渲染效果.
|
|
4
|
+
* 跑: npx tsx tests/preview.tsx (看完可删)
|
|
5
|
+
*/
|
|
6
|
+
import { mergeToolCalls, viewsForBlock, toolLabel } from '../src/lib/entry-view.js'
|
|
7
|
+
|
|
8
|
+
// ── 典型 entry 构造 (Claude SDK 形态) ──────────────────────────────────────
|
|
9
|
+
const entries: any[] = [
|
|
10
|
+
// 1. 用户提问
|
|
11
|
+
{ type: 'user', uuid: 'u1', message: { content: '帮我把 getData 改成 async 实现' } },
|
|
12
|
+
|
|
13
|
+
// 2. assistant 文本回复 (full 完整展示)
|
|
14
|
+
{ type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: '好的,改成 async/await 实现。\n\n主要改动:\n1. 加 async 关键字\n2. await 替换 .then()\n3. try/catch 错误处理' }] } },
|
|
15
|
+
|
|
16
|
+
// 3. Read 命令 + 结果 (compact, 合并成一块, ≤2 行)
|
|
17
|
+
{ type: 'assistant', uuid: 'a2', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/src/foo.ts' } }] } },
|
|
18
|
+
{ type: 'user', uuid: 'u2', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'export function getData() {\n return fetch(url);\n}\n\n// ... 还有 200 行省略' }] } },
|
|
19
|
+
|
|
20
|
+
// 4. Edit 代码修改 (full: old−/new+ 完整展示)
|
|
21
|
+
{ type: 'assistant', uuid: 'a3', message: { content: [{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/src/foo.ts', old_string: 'export function getData() {\n return fetch(url);\n}', new_string: 'export async function getData() {\n const res = await fetch(url);\n return res.json();\n}' } }] } },
|
|
22
|
+
{ type: 'user', uuid: 'u3', message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: 'The file /src/foo.ts has been updated.' }] } },
|
|
23
|
+
|
|
24
|
+
// 5. Bash 命令 + 长结果 (compact, 合并, ≤2 行)
|
|
25
|
+
{ type: 'assistant', uuid: 'a4', message: { content: [{ type: 'tool_use', id: 't3', name: 'Bash', input: { command: 'npm run typecheck' } }] } },
|
|
26
|
+
{ type: 'user', uuid: 'u4', message: { content: [{ type: 'tool_result', tool_use_id: 't3', content: '> @mobius-os/mobius typecheck\n> tsc --noEmit\n\nAll good. 还有非常多非常多的编译输出行全部应该被压成一行省略号...' }] } },
|
|
27
|
+
|
|
28
|
+
// 6. reasoning 思考 (compact, ≤2 行)
|
|
29
|
+
{ type: 'assistant', uuid: 'a5', message: { content: [{ type: 'thinking', thinking: '用户要改异步实现,先读代码理解同步逻辑,再用 async/await 重写。要注意错误处理和返回值结构。这段思考过程非常非常长,必须被 clampLines 硬截断到最多两行,超出部分末尾加省略号。' }] } },
|
|
30
|
+
|
|
31
|
+
// 7. 噪声 (应被隐藏, 不显示)
|
|
32
|
+
{ type: 'event_msg', uuid: 'n1', payload: { type: 'token_count', input_tokens: 1234, output_tokens: 567 } },
|
|
33
|
+
{ type: 'session_meta', uuid: 'n2', payload: { cwd: '/repo', model: 'gpt-5' } },
|
|
34
|
+
{ type: 'turn_context', uuid: 'n4', payload: { turn_id: 't0' } },
|
|
35
|
+
|
|
36
|
+
// 8. context_compacted (对齐 web: 不再隐藏, 显示成 system 行)
|
|
37
|
+
{ type: 'event_msg', uuid: 'n3', payload: { type: 'context_compacted' } },
|
|
38
|
+
|
|
39
|
+
// 9. error (full 完整展示)
|
|
40
|
+
{ type: 'event_msg', uuid: 'e1', payload: { type: 'error', message: '模型连接超时,请检查网络后重试' } },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// ── 模拟 Chat.tsx ViewLine 的格式 (带 ANSI 颜色) ─────────────────────────
|
|
44
|
+
const WIDTH = 76
|
|
45
|
+
const C = { red: '\x1b[31m', green: '\x1b[32m', cyan: '\x1b[36m', magenta: '\x1b[35m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m' }
|
|
46
|
+
|
|
47
|
+
function clamp(text: string, width: number, max: number): string[] {
|
|
48
|
+
if (!text) return ['']
|
|
49
|
+
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
50
|
+
const wrapped: string[] = []
|
|
51
|
+
for (const p of paras) {
|
|
52
|
+
if (p === '') { wrapped.push(''); continue }
|
|
53
|
+
for (let i = 0; i < p.length; i += width) wrapped.push(p.slice(i, i + width))
|
|
54
|
+
}
|
|
55
|
+
if (wrapped.length <= max) return wrapped
|
|
56
|
+
const t = wrapped.slice(0, max)
|
|
57
|
+
const last = t[max - 1]
|
|
58
|
+
t[max - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
|
|
59
|
+
return t
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`${C.dim}═══ 合并后渲染预览 (width=76, 对齐 web 过滤) ═══${C.reset}\n`)
|
|
63
|
+
const blocks = mergeToolCalls(entries)
|
|
64
|
+
console.log(`${C.dim}[原始 ${entries.length} 条 entry → 合并后 ${blocks.length} 个 block]${C.reset}\n`)
|
|
65
|
+
|
|
66
|
+
for (const block of blocks) {
|
|
67
|
+
for (const view of viewsForBlock(block)) {
|
|
68
|
+
switch (view.kind) {
|
|
69
|
+
case 'skip':
|
|
70
|
+
break
|
|
71
|
+
case 'user':
|
|
72
|
+
console.log(`\n${C.bold}› ${view.text}${C.reset}`)
|
|
73
|
+
break
|
|
74
|
+
case 'assistant':
|
|
75
|
+
console.log(`${C.bold}•${C.reset} ${view.text}`)
|
|
76
|
+
break
|
|
77
|
+
case 'tool_call': {
|
|
78
|
+
const head = clamp(`${toolLabel(view.toolName)} ${view.summary}`.trim(), WIDTH - 2, 1)[0]
|
|
79
|
+
console.log(`${C.cyan}• ${head}${C.reset}`)
|
|
80
|
+
if (view.result) console.log(`${C.dim} └ ${clamp(view.result.text, WIDTH - 4, 1)[0] || '(无输出)'}${C.reset}`)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
case 'code_edit':
|
|
84
|
+
console.log(`${C.magenta}✎ 编辑 ${view.filePath || '(未指定文件)'}${C.reset}`)
|
|
85
|
+
for (const l of view.oldString.split('\n')) console.log(` ${C.red}−${C.reset} ${l}`)
|
|
86
|
+
for (const l of view.newString.split('\n')) console.log(` ${C.green}+${C.reset} ${l}`)
|
|
87
|
+
break
|
|
88
|
+
case 'write_file':
|
|
89
|
+
console.log(`${C.magenta}✎ 写入 ${view.filePath || '(未指定文件)'}${C.reset}`)
|
|
90
|
+
for (const l of view.content.split('\n')) console.log(` ${C.green}+${C.reset} ${l}`)
|
|
91
|
+
break
|
|
92
|
+
case 'reasoning':
|
|
93
|
+
clamp(view.text, WIDTH - 4, 2).forEach((l, i) => console.log(`${C.dim}${C.magenta} ◇ ${l}${C.reset}`))
|
|
94
|
+
break
|
|
95
|
+
case 'system':
|
|
96
|
+
console.log(`${C.dim}${C.yellow} ${clamp(view.text, WIDTH - 2, 2)[0]}${C.reset}`)
|
|
97
|
+
break
|
|
98
|
+
case 'error':
|
|
99
|
+
console.log(`${C.red}⚠ ${view.text}${C.reset}`)
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(`\n${C.dim}═══ 预览结束 ═══${C.reset}`)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect regression — "TUI 输入经常显示两次" (input often renders twice).
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the optimistic `pendingUser` placeholder is cleared on the live
|
|
5
|
+
* `jsonl_entry` path but NOT when the same message is delivered via a reconnect's
|
|
6
|
+
* `jsonl_history` replay. Behind a reverse proxy (nginx idle timeout) the SSE
|
|
7
|
+
* stream drops mid-turn and auto-reconnects; the reconnect replays the session
|
|
8
|
+
* tail — which now contains the just-sent user message — so it lands in `entries`
|
|
9
|
+
* while `pendingUser` is still set, and the user's input is shown twice. If the
|
|
10
|
+
* whole turn finished while disconnected, no live entry ever arrives to clear the
|
|
11
|
+
* placeholder, so the duplication persists until the next send.
|
|
12
|
+
*
|
|
13
|
+
* This test drops the SSE stream right after the user sends "good" and has the
|
|
14
|
+
* reconnect replay history containing that user entry, then asserts "good" is
|
|
15
|
+
* rendered exactly once. Without the fix it renders twice.
|
|
16
|
+
*
|
|
17
|
+
* Run: npm run test:reconnect
|
|
18
|
+
*/
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
|
|
23
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-reconnect-'))
|
|
24
|
+
process.env.MOBIUS_TUI_HOME = TMP_HOME
|
|
25
|
+
|
|
26
|
+
import React from 'react'
|
|
27
|
+
import { render } from 'ink-testing-library'
|
|
28
|
+
import { App } from '../src/App.js'
|
|
29
|
+
|
|
30
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
31
|
+
const RS: any = (globalThis as any).ReadableStream
|
|
32
|
+
const enc = new TextEncoder()
|
|
33
|
+
let sseController: any = null
|
|
34
|
+
|
|
35
|
+
function json(body: unknown, status = 200) {
|
|
36
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let pass = 0, fail = 0
|
|
40
|
+
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
41
|
+
|
|
42
|
+
const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
|
|
43
|
+
let connectCount = 0
|
|
44
|
+
// Entries the next SSE connect should replay as jsonl_history (simulates the
|
|
45
|
+
// server's tail snapshot on reconnect containing the just-persisted user turn).
|
|
46
|
+
let pendingHistory: any[] = []
|
|
47
|
+
|
|
48
|
+
function mockFetch(url: string, init?: RequestInit): Response {
|
|
49
|
+
// ── SSE: a fresh stream per connect. Reconnects (connect >= 2) replay history. ─
|
|
50
|
+
if (url.includes('/events')) {
|
|
51
|
+
const n = ++connectCount
|
|
52
|
+
return new Response(new RS({
|
|
53
|
+
start(c: any) {
|
|
54
|
+
sseController = c
|
|
55
|
+
c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n'))
|
|
56
|
+
if (n >= 2 && pendingHistory.length) {
|
|
57
|
+
const payload = JSON.stringify({ event: 'jsonl_history', entries: pendingHistory, done: true })
|
|
58
|
+
c.enqueue(enc.encode(`event: jsonl_history\ndata: ${payload}\n\n`))
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
62
|
+
}
|
|
63
|
+
const method = init?.method ?? 'GET'
|
|
64
|
+
if (url.endsWith('/api/auth/config')) return json({ password_required: false })
|
|
65
|
+
if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
|
|
66
|
+
if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
|
|
67
|
+
// aimux bridge probe — satisfy it so ensureSession doesn't loop for 8s.
|
|
68
|
+
if (url.includes('/aimux_bridge/api/remotes/') && url.includes('/connection')) {
|
|
69
|
+
const m = url.match(/remotes\/([^/]+)\/connection/)
|
|
70
|
+
return json({ identifier: m ? decodeURIComponent(m[1]) : 'x', event_stream_connected: true })
|
|
71
|
+
}
|
|
72
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID })
|
|
73
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') return json([])
|
|
74
|
+
// Sending a message: persist it, then DROP the live stream so the only delivery
|
|
75
|
+
// path is the reconnect's history replay (the exact scenario that double-rendered).
|
|
76
|
+
if (url.endsWith('/messages') && method === 'POST') {
|
|
77
|
+
pendingHistory = [{ type: 'user', uuid: 'user-good-1', message: { role: 'user', content: 'good' } }]
|
|
78
|
+
setTimeout(() => { try { sseController?.close() } catch { /* ignore */ } }, 150)
|
|
79
|
+
return json({ ok: true, session_id: SID, turn_number: 1 })
|
|
80
|
+
}
|
|
81
|
+
if (url.endsWith(`/api/sessions/${SID}/status`)) return json({ session_id: SID, alive: true, working: false })
|
|
82
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' })
|
|
83
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([])
|
|
84
|
+
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }])
|
|
85
|
+
if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' })
|
|
86
|
+
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
|
|
87
|
+
if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
|
|
88
|
+
if (url.includes('/skills')) return json([])
|
|
89
|
+
if (url.includes('/memories')) return json([])
|
|
90
|
+
return json({ error: `unmocked ${method} ${url}` }, 404)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 6000) {
|
|
94
|
+
for (let i = 0; i < timeoutMs / 50; i++) {
|
|
95
|
+
if ((lastFrame() ?? '').includes(needle)) return true
|
|
96
|
+
await delay(50)
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Count non-overlapping occurrences of needle in s (after stripping ANSI codes). */
|
|
102
|
+
function countOccur(s: string, needle: string): number {
|
|
103
|
+
const clean = s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
104
|
+
let n = 0, i = 0
|
|
105
|
+
while ((i = clean.indexOf(needle, i)) !== -1) { n++; i += needle.length }
|
|
106
|
+
return n
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
|
|
111
|
+
server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
|
|
112
|
+
user: { id: 'tester', display_name: 'Test User', role: 'admin' },
|
|
113
|
+
}))
|
|
114
|
+
const realFetch = globalThis.fetch
|
|
115
|
+
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
116
|
+
|
|
117
|
+
console.log('\n[RECONNECT] input-not-duplicated regression (mocked backend)\n')
|
|
118
|
+
const { stdin, lastFrame, unmount } = render(React.createElement(App))
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// ── boot through the prep wizard into chat (same drive as flow.test) ─────────
|
|
122
|
+
ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
|
|
123
|
+
stdin.write('\r'); await delay(120)
|
|
124
|
+
ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
|
|
125
|
+
stdin.write('测试项目PTY'); await delay(120)
|
|
126
|
+
stdin.write('\r'); await delay(300)
|
|
127
|
+
ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
|
|
128
|
+
stdin.write('\r'); await delay(120)
|
|
129
|
+
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
|
|
130
|
+
stdin.write('命令行任务'); await delay(120)
|
|
131
|
+
stdin.write('\r'); await delay(300)
|
|
132
|
+
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
|
|
133
|
+
stdin.write('\r'); await delay(250)
|
|
134
|
+
ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
|
|
135
|
+
stdin.write('\r'); await delay(400)
|
|
136
|
+
ok(await waitFor(lastFrame, '输入问题'), 'entered chat')
|
|
137
|
+
|
|
138
|
+
// ── the bug: send "good", SSE drops, reconnect replays it via history ───────
|
|
139
|
+
connectCount = 0 // reset so the post-send reconnect is "connect 2"
|
|
140
|
+
stdin.write('good'); await delay(120)
|
|
141
|
+
stdin.write('\r')
|
|
142
|
+
// The user's message shows instantly via the optimistic placeholder, so do NOT
|
|
143
|
+
// gate on "good" appearing — gate on the reconnect actually firing (connect 2),
|
|
144
|
+
// which is what replays history and (without the fix) double-renders the input.
|
|
145
|
+
let reconnected = false
|
|
146
|
+
for (let i = 0; i < 8000 / 50; i++) {
|
|
147
|
+
if (connectCount >= 2) { reconnected = true; break }
|
|
148
|
+
await delay(50)
|
|
149
|
+
}
|
|
150
|
+
ok(reconnected, 'SSE dropped and reconnected after the send')
|
|
151
|
+
await delay(500) // let the reconnect's history replay render
|
|
152
|
+
|
|
153
|
+
const frame = lastFrame() ?? ''
|
|
154
|
+
const occurrences = countOccur(frame, 'good')
|
|
155
|
+
console.log(` i occurrences of "good" in frame: ${occurrences}`)
|
|
156
|
+
ok(occurrences === 1, `user input rendered exactly once (got ${occurrences}; would be 2 without the fix)`)
|
|
157
|
+
if (occurrences !== 1) {
|
|
158
|
+
console.log('── frame ──\n' + frame.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\n{3,}/g, '\n\n').trim())
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
unmount()
|
|
162
|
+
globalThis.fetch = realFetch
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
166
|
+
console.log(`\n==== RECONNECT RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
167
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
main().catch((e) => { console.error('FATAL', e); process.exit(2) })
|