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:
@@ -187,7 +187,11 @@ export interface EmptyStateInjected {
|
||||
releaseDraftImage(id: string): void
|
||||
/** Release all service-owned image previews held by the empty state. */
|
||||
releaseDraftImages(attachments: readonly ComposerAttachment[]): void
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
/**
|
||||
* The create → first-send → navigate chain, in one service call. Navigation
|
||||
* happens only after the send is accepted, so a failure leaves the empty
|
||||
* state and its draft mounted.
|
||||
*/
|
||||
startSession(opts: {
|
||||
cwd?: string
|
||||
text: string
|
||||
|
||||
@@ -209,12 +209,12 @@ export class ConversationService extends Service {
|
||||
|
||||
/**
|
||||
* Empty-state first-send chain (root-context method; does not read scope):
|
||||
* create the session, navigate to it, then send through the new scope.
|
||||
* The create → open ordering is safe: the manager merges the new summary
|
||||
* synchronously before create() resolves, so the list store is projected by
|
||||
* the time open() validates against it (manager notification batching is
|
||||
* microtask-based; SessionsService projects on the same flush that create
|
||||
* awaited through the RPC round trip).
|
||||
* create the session, send through the new scope, and navigate only after
|
||||
* the send is accepted. Navigation is the publication point — opening
|
||||
* earlier would unmount the empty state (releasing its draft previews)
|
||||
* while the send can still fail, leaving the failure with no surface and
|
||||
* the user with a lost draft; on rejection here the still-mounted empty
|
||||
* state keeps the draft and shows the error locally.
|
||||
* @param opts - project directory, prompt text, images, and send mode.
|
||||
*/
|
||||
async startSession(opts: {
|
||||
@@ -226,9 +226,10 @@ export class ConversationService extends Service {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
// list-store projection landed before sessions.open validates against it.
|
||||
// list-store projection landed before sessions.open validates against it
|
||||
// (the manager merges the new summary synchronously before create()
|
||||
// resolves; batching is microtask-based).
|
||||
await Promise.resolve()
|
||||
sessions.open(id)
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
||||
// ctx.get, not scoped.conversation: property access walks the fiber
|
||||
@@ -237,6 +238,7 @@ export class ConversationService extends Service {
|
||||
const scopedConversation = scoped.get('conversation')
|
||||
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
|
||||
await scopedConversation.send(opts.text, opts.mode, opts.images ?? [])
|
||||
sessions.open(id)
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user