fix(gui): address ds-review-bot findings on image attachments

- dsh web gains --provider/--model: a non-deepseek provider mounts the
  matching pi-ai catalog route (ambient credentials), making image input
  reachable from the shipped Web assembly; requires an explicit --model
- attachment-local syncs the publication directories after the hard-link
  publish so a reported durable reference survives a crash (POSIX; Windows
  relies on filesystem metadata journaling)
- the attachment seam gains storage-free validateImage; the host validates a
  complete multi-image prompt before persisting any member, so one malformed
  image cannot strand valid members as unreferenced objects
- startSession sends before navigating: a rejected first send keeps the empty
  state, its error strip, and the complete draft mounted
- the webserver rejects an undeclared-length body the moment it crosses the
  configured limit instead of draining a potentially endless stream to EOF
This commit is contained in:
creatixchu
2026-07-24 20:55:25 +08:00
parent b868355f01
commit f57a4a044a
23 changed files with 217 additions and 41 deletions
@@ -2,7 +2,7 @@
/**
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), and the service-unavailable
* chain (create → scoped send → sessions.open), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
@@ -280,13 +280,23 @@ describe('image admission and URL lifecycle', () => {
})
describe('startSession chain', () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
it('creates, sends through the new scope, then navigates through sessions.open', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
const prompt = b.sessionDoubles.get(sid('new-1'))!.prompt
expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: 'first' }], 'queue')
// Navigation is the publication point: it must not precede send acceptance.
expect(b.openMock.mock.invocationCallOrder[0]!).toBeGreaterThan(prompt.mock.invocationCallOrder[0]!)
})
it('does not navigate when the first send is rejected (empty state keeps the draft)', async () => {
const b = await bench()
const doomed = b.sessionsFake.manager.get(sid('new-1')) as unknown as SessionDouble
doomed.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'nope' } })
await expect(b.svc.startSession({ text: 'first', mode: 'queue' })).rejects.toThrow(/agent-busy/)
expect(b.openMock).not.toHaveBeenCalled()
})
it('omits cwd from create when not chosen', async () => {