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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26
|
||||
README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0
|
||||
README.md: 4a4ce324a0482072164d3c4f6badd464bfacfc6f
|
||||
README.zh.md: 7fb421ae206563a625df5bd53fbcef1f585bd269
|
||||
|
||||
@@ -22,5 +22,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`attachments` is a hard inject** — the route plugin (and `host-apiproxy`) will not mount until an attachment backend provides `ctx.attachments`, and a composition missing one stalls silently as a cordis inject gap rather than failing loud; text-only deployments therefore still carry the native `sharp` dependency through `attachment-local`. A capability-degraded (image-refusing) mount is deliberate deferred work.
|
||||
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
|
||||
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
|
||||
|
||||
@@ -22,5 +22,6 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`attachments` 是硬性注入依赖**:路由插件(以及 `host-apiproxy`)在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败;因此纯文本部署也会经由 `attachment-local` 携带原生 `sharp` 依赖。降级为拒绝图片的挂载方式是有意延期的工作。
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -114,7 +114,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: {
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user