fix(fs): close read-image review gaps

This commit is contained in:
creatixchu
2026-08-10 19:56:18 +08:00
parent 21ec0fd10b
commit 767e5a5723
9 changed files with 49 additions and 11 deletions

View File

@@ -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/support/llm-replay/README.md
README.md: ade19b66f96ef26119944cbebba23c43e4b2117c
README.zh.md: 4752e03b04be175d7f3e37fc57fe9c74e1f8a303
README.md: 6119407c06cf734166300f5f70a3fe65d026d08d
README.zh.md: 75d74b654a9a53e7ada5cc854e275eadcbe01284

View File

@@ -29,7 +29,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and declared `inputModalities` (so a scenario can exercise capability gates such as the image-capable `read_image` route check); configured routes dispatch through the replay adapter and never perform provider I/O. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and an `inputModalities` array containing only `text` and `image`; invalid modalities fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. |
| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. |
```yaml

View File

@@ -29,7 +29,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
| `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env。 |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 |
| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`声明的 `inputModalities`(让场景能够触发能力门禁,例如 `read_image` 的图像路由检查);已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`仅包含 `text``image``inputModalities` 数组;模态配置无效时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
| `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSEServer-Sent Events多路复用器看到真正的增量传递。它只是仿真开关测试不得依赖它保证正确性。值必须是非负整数pace 等待期间中止会迅速取消流。 |
```yaml

View File

@@ -765,11 +765,28 @@ export interface Config {
paceMs?: number
}
function validateConfiguredModalities(providers: ReplayProviderConfig[] | undefined): void {
for (const provider of providers ?? []) {
for (const model of provider.models ?? []) {
const modalities: unknown = model.inputModalities
if (modalities === undefined) continue
if (!Array.isArray(modalities)
|| !modalities.every((modality: unknown) => modality === 'text' || modality === 'image')) {
throw new Error(
`llm-replay: provider "${provider.id}" model "${model.id}" inputModalities `
+ 'must be an array containing only "text" and "image"',
)
}
}
}
}
export function apply(ctx: Context, config: Config = {}): void {
const file = config.file ?? process.env.DSH_SNAPSHOT_FILE
if (file === undefined || file.length === 0) {
throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
}
validateConfiguredModalities(config.providers)
const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES
const childFiles = config.childFiles

View File

@@ -7,6 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CompactionId } from '@deepseek-ai/dsh-compact'
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
import {
type Config,
type ReplayEntry,
type SessionScript,
apply,
@@ -1097,11 +1098,31 @@ describe('apply (the plugin entry)', () => {
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 })
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }])
apply(ctx, {
file,
providers: [
{ id: 'm', models: [{ id: 'm', inputModalities: ['image'] }, { id: 'text' }] },
{ id: 'empty' },
],
paceMs: 1,
})
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }, { id: 'empty', name: 'empty' }])
await expect(ctx.llm.resolveModelInfo('m', 'm')).resolves.toMatchObject({ inputModalities: ['image'] })
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})
it.each([
['a string', 'image'],
['an unknown modality', ['audio']],
])('rejects inputModalities configured as %s during load', (_case, inputModalities) => {
const ctx = new Context()
const providers = [{ id: 'm', models: [{ id: 'm', inputModalities }] }] as unknown as
NonNullable<Config['providers']>
expect(() => { apply(ctx, { file, providers }) }).toThrow(
'llm-replay: provider "m" model "m" inputModalities must be an array containing only "text" and "image"',
)
})
it('falls back to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE when config is empty', async () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')