Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	apps/web/tests/code-mode-fixture.snapshot.ts
#	docs/architecture.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/module-graph.md
#	packages/README.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/test-runtime/tests/runtime.spec.tsx
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/summarizer.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
#	packages/ui/tui/README.i18n.yaml
#	pnpm-lock.yaml
This commit is contained in:
creatixchu
2026-07-30 10:36:41 +08:00
935 changed files with 41976 additions and 6862 deletions

View File

@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -117,6 +118,7 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

View File

@@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
@@ -27,6 +28,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
@@ -35,6 +37,52 @@ describe('FoldAdapter', () => {
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
@@ -114,6 +162,68 @@ describe('FoldAdapter', () => {
}
})
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
@@ -130,6 +240,44 @@ describe('FoldAdapter', () => {
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
const at = (seq: number, type: string, data: unknown): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
events.map(event => ({ event }))
describe('inspectRequests', () => {
it('projects ordinary and compaction calls into one chronological request stream', () => {
const events = [
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'system',
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'done' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 5, outputTokens: 2 },
}),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'compact/start', { turn: 1 }),
at(6, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
rawOutput: [
{ type: 'reasoning', text: 'thought' },
{ type: 'text', text: 'summary' },
],
provider: 'fake',
model: 'compact-model',
usage: { inputTokens: 8, outputTokens: 3 },
}),
at(7, 'user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
})),
at(8, 'compact/end', { turn: 1 }),
]
const snapshot = inspectRequests(entriesOf(events))
expect(snapshot.requests).toMatchObject([
{
purpose: 'assistant',
startSeq: 0,
resultSeq: 3,
status: 'complete',
prompt: {
config: { provider: 'fake', model: 'model' },
system: 'system',
},
promptChange: { seq: 1, kind: 'initial' },
},
{
purpose: 'compaction',
startSeq: 5,
resultSeq: 6,
replacementSeq: 7,
status: 'complete',
summary: [{ type: 'text', text: 'summary' }],
},
])
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(1, 'tool/code-dispatch-start', {
parentCallId: 'parent',
subCallId: 'nested',
name: 'read',
arguments: {},
}),
]))
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
const retryUsage = {
inputTokens: 5,
outputTokens: 2,
cacheReadTokens: 8,
reasoningTokens: 1,
}
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: chunkUsage },
}),
at(2, 'llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 100,
failure: { message: 'rate limited' },
}),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: retryUsage },
}),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'recovered' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
}),
]))
expect(snapshot.requests[0]).toMatchObject({
status: 'complete',
usage: {
inputTokens: 26,
outputTokens: 5,
cacheReadTokens: 8,
reasoningTokens: 1,
},
})
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: '{{tools}}',
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
]))
expect(snapshot.callSchemas).toEqual(new Map())
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
})
})

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
return histResponse(pages[0]!, false)
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
it('pins a lazy inspection to the entries in its source snapshot', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const before = source.getSnapshot()
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.user(6, 'later'),
})
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 6])
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
: Promise.resolve(err({
code: 'internal',
message: 'page unavailable',
details: {},
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
}
olderStarted.resolve(undefined)
return middle.promise
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
await complete
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
})

View File

@@ -316,6 +316,32 @@ describe('prompt and cancel errors', () => {
})
})
describe('rename', () => {
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
const result = await session.rename(' 正名 ')
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
// A stale lower-seq apply (the push-frame path routes into this same
// store) must not roll the settled value back.
session.projections.apply('title', '旧名', 3)
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
})
it('returns the business error untouched and folds a transport throw to internal', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
const rejected = await session.rename(' ')
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
api.onRename = () => Promise.reject(new Error('rename transport down'))
const folded = await session.rename('x')
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
})
})
describe('pending interactions', () => {
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()
@@ -716,6 +742,7 @@ describe('resync', () => {
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
})
describe('run_code sub-dispatch indexing', () => {
@@ -829,20 +856,23 @@ describe('reference stability (the memo contract)', () => {
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
feed(ev.stepStart(7, 1))
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
const before = session.getSnapshot()
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '与工具无关的流式'))
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '与工具无关的流式'))
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
feed(ev.assistant(12, 1, '完成'))
expect(session.getSnapshot()).not.toBe(resolved)
})
})

View File

@@ -286,3 +286,78 @@ describe('WorkspacesService', () => {
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})
describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces }
}
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
const b = bench()
const stop = b.workspaces.startInitialSelection()
// Nothing happens before both baselines land.
expect(b.api.callsOf('session.create')).toHaveLength(0)
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
await b.workspaces.refresh()
await b.sessions.refresh()
// Store notifications and the connect round trip are microtask-batched.
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
stop()
})
it('stays idle when a session is already current or no recent Workspace exists', async () => {
const withCurrent = bench()
withCurrent.api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
}))
await withCurrent.sessions.refresh()
withCurrent.sessions.open(sid('s1'))
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
const stopCurrent = withCurrent.workspaces.startInitialSelection()
await withCurrent.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
stopCurrent()
const noRecent = bench()
const stopEmpty = noRecent.workspaces.startInitialSelection()
await noRecent.workspaces.refresh()
await noRecent.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
stopEmpty()
})
it('a failed connect returns to waiting and retries on the next list change', async () => {
const b = bench()
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
const stop = b.workspaces.startInitialSelection()
await b.workspaces.refresh()
await b.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(1)
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
// Recovery: the next workspace-list change re-runs the reconcile.
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
await b.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(2)
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
stop()
})
})