Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706
# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md # .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml # apps/web/tests/scaffold.ts # docs/module-graph.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/package.json # packages/client/test-runtime/README.i18n.yaml # packages/client/test-runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-theme/README.i18n.yaml # packages/client/ui-theme/README.md # packages/client/ui-theme/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md # pnpm-lock.yaml
This commit is contained in:
@@ -1,21 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-deliverables browser half: the derivation contract of
|
||||
* `producedForClosing` over finalized snapshot nodes, the row's rendering
|
||||
* `producedForClosing` over engine-published Turn data, the row's rendering
|
||||
* and opener wiring, and the plugin registrations' fiber-teardown removal
|
||||
* (HMR safety) against the real SlotsService.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
|
||||
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
|
||||
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
|
||||
ConversationViewNode, ToolResultNode, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
type DeliverablesTurnData,
|
||||
} from '../src/client/turn-deliverables.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
@@ -23,101 +31,238 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user',
|
||||
seq,
|
||||
time: seq * 1000,
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
|
||||
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
): Readonly<ConversationTurnDataMap[Key]> | undefined {
|
||||
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
}
|
||||
|
||||
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
value: ConversationTurnDataMap[Key],
|
||||
): void {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => {
|
||||
const data = new TestTurnDataStore()
|
||||
if (deliverables !== undefined) data.set('deliverables', deliverables)
|
||||
return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data }
|
||||
}
|
||||
|
||||
const produced = (...values: ReadonlyArray<readonly [seq: number, path: string]>): DeliverablesTurnData => ({
|
||||
produced: values.map(([seq, path]) => ({ seq, path })),
|
||||
})
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [],
|
||||
})
|
||||
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
|
||||
function tailOwner(
|
||||
data: DeliverablesTurnData | undefined,
|
||||
seq: number,
|
||||
openFile: (path: string) => void = () => {},
|
||||
turn = 1,
|
||||
): TurnTailOwnerProps {
|
||||
return { seq, openFile, turn: turnLocation(turn, data) }
|
||||
}
|
||||
|
||||
interface TimelineSnapshot {
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] }
|
||||
fallbackEntry(): undefined { return undefined }
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] }
|
||||
}
|
||||
|
||||
const timelineViewDefinition: ConversationViewDefinition<ConversationViewNode, TimelineSnapshot> = {
|
||||
target: 'test',
|
||||
create: () => {
|
||||
let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } }
|
||||
return {
|
||||
empty: current,
|
||||
replace: ({ timeline }) => (current = { timeline }),
|
||||
apply: ({ timeline }) => (current = { timeline }),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
view?: ConversationEventInput['view'],
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq, time: seq * 1_000, type, data,
|
||||
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
|
||||
} as ConversationEventInput['event'],
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
|
||||
return { ...input, role, location: { kind: 'unresolved' } }
|
||||
}
|
||||
|
||||
function call(
|
||||
seq: number,
|
||||
callId: string,
|
||||
view: ToolResultNode['callView'],
|
||||
turn = 1,
|
||||
): ConversationEventInput {
|
||||
return at(
|
||||
seq,
|
||||
'tool/call',
|
||||
{ turn, step: 1, callId, name: 'fixture', arguments: '{}' },
|
||||
{ for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
|
||||
)
|
||||
}
|
||||
|
||||
function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput {
|
||||
return at(seq, 'tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { type: 'tool-result', callId },
|
||||
content: [{ type: 'tool-result', content: [], isError }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function diff(...paths: string[]): ToolResultNode['callView'] {
|
||||
return {
|
||||
card: 'diff', title: `Write ${paths[0] ?? ''}`,
|
||||
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
|
||||
locations: paths.map(path => ({ path })),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('producedForClosing derivation', () => {
|
||||
it('attributes each turn’s written files to the assistant that closes it', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing', 1),
|
||||
wrote(3, 'a', 'out/index.html'),
|
||||
// Same file touched twice in one turn is one deliverable, in first-seen order.
|
||||
wrote(4, 'b', 'out/app.css', 'out/index.html'),
|
||||
// A read is not a deliverable; a failed write has no file to open.
|
||||
{ ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } },
|
||||
{ ...wrote(6, 'd', 'out/broken.html'), isError: true },
|
||||
assistant(7, 'done', 1),
|
||||
user(8, 'again'),
|
||||
assistant(9, 'second turn', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
|
||||
// A turn that produced nothing yields the empty list, and so does an
|
||||
// anchor the window does not contain.
|
||||
expect(producedForClosing(nodes, 9)).toEqual([])
|
||||
expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([])
|
||||
expect(producedForClosing(nodes, 999)).toEqual([])
|
||||
function edit(path: string): ToolResultNode['callView'] {
|
||||
return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly<DeliverablesTurnData> | undefined {
|
||||
const snapshot = value.snapshot('test') as TimelineSnapshot
|
||||
return snapshot.timeline.turns.get(turn)?.data.get('deliverables')
|
||||
}
|
||||
|
||||
describe('produced-file Turn data', () => {
|
||||
it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => {
|
||||
const data = produced(
|
||||
[3, 'out/index.html'],
|
||||
[4, 'out/app.css'],
|
||||
[4, 'out/index.html'],
|
||||
[8, 'after.txt'],
|
||||
)
|
||||
expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(producedForClosing(undefined)).toEqual([])
|
||||
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
|
||||
})
|
||||
|
||||
it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'write', diff('out/index.html', 'out/app.css')),
|
||||
result(3, 'write'),
|
||||
call(4, 'edit', edit('notes.md')),
|
||||
result(5, 'edit'),
|
||||
call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
|
||||
result(7, 'read'),
|
||||
call(8, 'failed', diff('broken.txt')),
|
||||
result(9, 'failed', true),
|
||||
call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
|
||||
result(11, 'locationless'),
|
||||
])
|
||||
|
||||
it('counts a generic edit and never spills across the turn boundary', () => {
|
||||
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'str_replace_editor'),
|
||||
// str_replace_editor's insert mutates behind a generic card, so the
|
||||
// discriminant is the render intent, not the card shape alone.
|
||||
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
|
||||
})
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'insert a line'),
|
||||
inserted(2, 'i', 'notes.md'),
|
||||
assistant(3, 'inserted', 1),
|
||||
// Turn 2 mutates and then ends with no content text (interrupted, or its
|
||||
// last text preceded the tool): its paths must not ride into turn 3.
|
||||
user(4, 'now rewrite it'),
|
||||
wrote(5, 'w', 'leaked.txt'),
|
||||
user(6, 'and again'),
|
||||
wrote(7, 'w2', 'notes.md'),
|
||||
assistant(8, 'done', 3),
|
||||
]
|
||||
expect(producedForClosing(nodes, 3)).toEqual(['notes.md'])
|
||||
// Turn 3 lists only its own file — and the dedup set did not suppress the
|
||||
// rewrite of a path an earlier turn already touched.
|
||||
expect(producedForClosing(nodes, 8)).toEqual(['notes.md'])
|
||||
expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt')
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([
|
||||
'out/index.html', 'out/app.css', 'notes.md',
|
||||
])
|
||||
})
|
||||
|
||||
it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'go'),
|
||||
// A turnless surface node neither tracks nor resets the boundary.
|
||||
{ kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null },
|
||||
wrote(2, 'w', 'turn-one.txt'),
|
||||
// A view-less result (window truncation) and cards without locations
|
||||
// contribute nothing rather than crashing the walk.
|
||||
toolResult(3, 'plain'),
|
||||
{ ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } },
|
||||
{ ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } },
|
||||
assistant(6, 'mid narration', 1),
|
||||
// Turn number advances with no user message in the window (truncated
|
||||
// history): the accumulator must reset all the same.
|
||||
assistant(7, 'closing', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt'])
|
||||
expect(producedForClosing(nodes, 7)).toEqual([])
|
||||
it('ignores calls without mutation locations, orphan results, and replacement results', () => {
|
||||
const replacement = result(8, 'replacement')
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
|
||||
result(3, 'no-view'),
|
||||
call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
|
||||
result(5, 'locationless-edit'),
|
||||
result(6, 'orphan'),
|
||||
call(7, 'replacement', diff('replaced.txt')),
|
||||
{
|
||||
...replacement,
|
||||
event: {
|
||||
...replacement.event,
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
} as ConversationEventInput['event'],
|
||||
},
|
||||
at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an invalid start match and preserves state for an unrelated update', () => {
|
||||
const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start')
|
||||
const emptyContext: Parameters<typeof deliverablesDefinition.start>[0] = {
|
||||
key: 'deliverables:1',
|
||||
kind: 'deliverables',
|
||||
id: '1',
|
||||
matches: [startMatch],
|
||||
start: startMatch,
|
||||
state: undefined,
|
||||
current: new Map(),
|
||||
}
|
||||
const reader: Parameters<typeof deliverablesDefinition.start>[2] = { previous: () => undefined }
|
||||
const state = deliverablesDefinition.start(emptyContext, startMatch, reader)
|
||||
const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update')
|
||||
const context: Parameters<typeof deliverablesDefinition.update>[0] = { ...emptyContext, state }
|
||||
|
||||
expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader))
|
||||
.toThrow('deliverables start requires turn/start')
|
||||
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
|
||||
})
|
||||
|
||||
it('replays a tail page once prepend supplies its missing Turn start', () => {
|
||||
const value = assembler([
|
||||
call(10, 'late', diff('history.txt')),
|
||||
result(11, 'late'),
|
||||
], true)
|
||||
expect(deliverablesOf(value)).toBeUndefined()
|
||||
|
||||
value.prepend([at(1, 'turn/start', { turn: 1 })], false)
|
||||
value.flush()
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt'])
|
||||
})
|
||||
|
||||
it('extends the same Turn data incrementally on live append', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
call(2, 'first', diff('first.txt')),
|
||||
result(3, 'first'),
|
||||
])
|
||||
const first = deliverablesOf(value)
|
||||
expect(producedForClosing(first)).toEqual(['first.txt'])
|
||||
|
||||
value.append(call(4, 'second', diff('second.txt')))
|
||||
value.append(result(5, 'second'))
|
||||
value.flush()
|
||||
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,6 +287,33 @@ describe('ProducedFiles row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('producedFileMentions resolver', () => {
|
||||
const label = (path: string) => `打开 ${path}`
|
||||
|
||||
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
|
||||
const opened: string[] = []
|
||||
const resolver = producedFileMentions(
|
||||
['out/index.html', 'a/style.css', 'b/style.css'],
|
||||
(path) => { opened.push(path) },
|
||||
label,
|
||||
)
|
||||
// Unique basename resolves to its full path; the full path rides title.
|
||||
const byBasename = resolver.resolve('index.html')
|
||||
expect(byBasename?.label).toBe('打开 out/index.html')
|
||||
expect(byBasename?.title).toBe('out/index.html')
|
||||
byBasename?.open()
|
||||
expect(opened).toEqual(['out/index.html'])
|
||||
// An exact path resolves even when its basename is ambiguous.
|
||||
const exact = resolver.resolve('a/style.css')
|
||||
expect(exact?.title).toBe('a/style.css')
|
||||
// A basename two paths share stays unresolved rather than guessing,
|
||||
// and so does a token naming nothing the turn wrote.
|
||||
expect(resolver.resolve('style.css')).toBeUndefined()
|
||||
expect(resolver.resolve('notes.md')).toBeUndefined()
|
||||
expect(basename('a\\b\\c.txt')).toBe('c.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package shells', () => {
|
||||
it('the node half mounts inert and the invariant companion registers ownership', async () => {
|
||||
// The node half is deliberately inert; mounting it must simply not throw.
|
||||
@@ -162,6 +334,7 @@ describe('plugin registration', () => {
|
||||
it('registers the tail entry and fiber disposal removes it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
// The owning view's child declaration, stood up by a bench root entry.
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
@@ -174,7 +347,24 @@ describe('plugin registration', () => {
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
const owner = tailOwner(
|
||||
produced([2, 'site/report.html']),
|
||||
3,
|
||||
(path) => { opened.push(path) },
|
||||
)
|
||||
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
|
||||
const mentions = service?.forClosing(owner)
|
||||
mentions?.resolve('report.html')?.open()
|
||||
expect(opened).toEqual(['site/report.html'])
|
||||
// A turn that produced nothing yields no vocabulary at all.
|
||||
expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
|
||||
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user