feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)

三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
This commit is contained in:
Atlas Zeng
2026-08-21 15:41:14 +08:00
committed by GitHub
parent 50a888845f
commit 7f4f84c4cf
27 changed files with 3501 additions and 13 deletions
+74 -1
View File
@@ -56,9 +56,11 @@ const SEEDED_MD_FILE = 'diagram.md'
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Side Chat', 'Terminal', 'Browser']
let api: APIRequestContext
/** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */
let seededSessionId: string
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
@@ -99,6 +101,11 @@ async function seedSession(): Promise<void> {
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
const sessionBody = (await session.json()) as {
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
}
expect(sessionBody.result.ok).toBe(true)
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
}
test.beforeAll(async () => {
@@ -228,6 +235,72 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash()
}
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
// (keyless boot has no model route), but admission + creation + the wire
// envelope must all succeed — this is the deepest functional proof the
// mount lane can make without a provider.
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
})
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
const startBody = (await start.json()) as {
ok: boolean
value?: { childId?: string }
error?: { code?: string; message?: string }
}
expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true)
const childId = startBody.value?.childId
expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/)
// The child must be a REAL session in the host's store (provider-free
// proof of the custom-seed creation; the boundary message itself only
// becomes durable when a turn claims it, which needs a model route).
const list = await api.post(`${BASE_URL}/api/session.list`, {
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
})
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
const listBody = (await list.json()) as {
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
}
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
expect(
listedItems.some(item => item.sessionId === childId),
'the thread child must appear in the host session list',
).toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
})
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
}
// The Codex-style immediate-create flow: a blank question creates an
// EMPTY thread (no prompt admitted), sidechat.info reports the live
// agent, and the first prompt delivers the boundary host-side.
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: '' },
})
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
const emptyChildId = emptyBody.value?.childId
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
data: { childId: emptyChildId },
})
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } }
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
})
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
}
await assertNoCrash()
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree