fix: address ds-review-bot v7 findings on the merged image-input head

- gate model selection on steering-placement image carriers from enqueue
  until their steering/message event publishes; release the gate when an
  admission ends idle without publication (both behaviorally asserted)
- reject session.updateQueue edits carrying non-text blocks at the RPC
  boundary (queue edits cannot bypass image admission)
- extend the durable-directory walk past a first-created DSH_HOME to the
  deepest pre-existing ancestor
- strip Windows-style separators from attachment display names on POSIX
- verify attachment reads with a header-only probe (digest already proves
  the bytes decoded fully at admission); document the read path
- make SessionInputShell.addImages refusal observable and keep workspace
  transfers/composer intake from leaking refused drafts
- own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy,
  pi-ai, compact-basic, and the DeepSeek text-only assertion
- drop the redundant canonical-base64 regex and the no-op role read
- move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts);
  document why AttachmentError does not extend HarnessError
- document the hard attachments inject in both consumer READMEs
This commit is contained in:
creatixchu
2026-07-30 14:34:08 +08:00
parent 97cf33b7e0
commit 0d1250f743
37 changed files with 335 additions and 140 deletions

View File

@@ -147,8 +147,10 @@ export function apply(ctx: Context): void {
next.setDraft(draft)
from.setDraft('')
}
if (imageIds.length > 0) {
next.addImages(imageIds)
// Transfer only on acceptance: a destination shell mid-submission
// refuses, and the drafts must stay owned (and releasable) by the
// source shell instead of silently leaking their object URLs.
if (imageIds.length > 0 && next.addImages(imageIds)) {
for (const id of imageIds) from.removeImage(id)
}
}
@@ -199,7 +201,12 @@ export function apply(ctx: Context): void {
addImages: (files) => {
try {
const images = conversation.createDraftImages(files)
shell.addImages(images.map(image => image.id))
if (!shell.addImages(images.map(image => image.id))) {
// Refused intake (machineBusy raced a submission): release the
// just-created previews instead of stranding their object URLs.
conversation.releaseDraftImages(images)
return null
}
return null
} catch (error: unknown) {
return error instanceof Error ? error.message : String(error)

View File

@@ -32,8 +32,12 @@ export interface InputTarget {
export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly DraftAttachmentId[]): void
/**
* Append ordered browser-owned draft attachment ids.
* @returns whether the ids were appended; busy admission phases refuse, and
* the caller keeps ownership of refused ids (release or retry them).
*/
addImages(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned draft attachment id. */
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser objects no longer exist. */
@@ -69,8 +73,11 @@ export interface InputService {
export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly DraftAttachmentId[]): void
/**
* Append ordered browser-owned draft attachment ids.
* @returns whether the ids were appended (busy admission phases refuse).
*/
addImages(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned draft attachment id. */
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser objects no longer exist. */

View File

@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
addImages: (ids) => { this.addImages(ids) },
addImages: ids => this.addImages(ids),
removeImage: (id) => { this.removeImage(id) },
pruneImages: (ids) => { this.pruneImages(ids) },
submit: (mode) => { this.submit(mode) },
@@ -101,11 +101,16 @@ export class SessionInputShell implements SessionInput {
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
}
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly DraftAttachmentId[]): void {
if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
/**
* Append ordered browser-owned draft attachment ids.
* @returns whether the ids were appended (busy admission phases refuse).
*/
addImages(ids: readonly DraftAttachmentId[]): boolean {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
if (ids.length === 0) return true
this.imageIds = [...this.imageIds, ...ids]
this.publish()
return true
}
/** Remove one browser-owned draft attachment id. */

View File

@@ -114,7 +114,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useInput: (() => { throw new Error('unused') }),
inputActions: {
setDraft: () => {},
addImages: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},

View File

@@ -80,7 +80,7 @@ describe('render branch tails', () => {
useInput={(() => { throw new Error('unused') })}
inputActions={{
setDraft: () => {},
addImages: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
@@ -122,7 +122,7 @@ describe('render branch tails', () => {
useInput={(() => { throw new Error('unused') })}
inputActions={{
setDraft: () => {},
addImages: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},

View File

@@ -339,6 +339,26 @@ describe('machine pending lock', () => {
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
})
it('addImages reports refusal in busy phases so callers keep draft ownership', () => {
const { shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{
token: '/goal ',
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
})
expect(shell.snapshot.phase).toBe('submitting')
// A refused batch must be observable (the workspace-switch transfer keeps
// the source shell's drafts alive instead of leaking their object URLs).
expect(shell.addImages(['busy-1' as never])).toBe(false)
expect(shell.snapshot.imageIds).toEqual([])
})
})
describe('decorations', () => {

View File

@@ -395,7 +395,7 @@ describe('DetailsPanel Output section', () => {
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
@@ -566,7 +566,7 @@ describe('DetailsPanel Output section', () => {
baselinesReady: true, recentWorkspaceId: undefined,
}))}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}