Merge branch 'master' into codex/goal-clear-single-flight
This commit is contained in:
@@ -388,6 +388,9 @@ export class SessionsService implements ISessions {
|
||||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||||
* anchor in an open turn is unavailable rather than clipped backward),
|
||||
* and whether to increment an inherited durable title before resolving.
|
||||
* A fractional anchor floors to a real event seq: the frozen nodes of an
|
||||
* interrupted turn carry flow-ordering seqs between two events, and the
|
||||
* wire takes integers only.
|
||||
* @returns the child session id.
|
||||
* @throws {SessionForkError} with the source id.
|
||||
* @throws {Error} when a requested child-title rename fails after creation.
|
||||
@@ -402,7 +405,10 @@ export class SessionsService implements ISessions {
|
||||
: undefined
|
||||
const result = await this.manager.fork({
|
||||
sessionId: opts.sessionId,
|
||||
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
|
||||
// Flooring lands inside the anchor's own turn (every turn opens with a
|
||||
// turn/start), so the host's first-turn/end-at-or-after cut still ends
|
||||
// on that turn — never clipped back to the previous one.
|
||||
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
|
||||
})
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
|
||||
@@ -455,6 +455,17 @@ describe('fork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
|
||||
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
|
||||
|
||||
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
|
||||
})
|
||||
|
||||
it('does not rename without the title policy or a durable source title', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
|
||||
@@ -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/ui-primitives/README.md
|
||||
README.md: f06e8c014c19d17197980a232e8b42080aac2901
|
||||
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
|
||||
README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b
|
||||
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20
|
||||
|
||||
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -23,13 +23,16 @@
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"anser": "^2.3.5",
|
||||
"clsx": "^2.0.0",
|
||||
"katex": "^0.16.47",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
|
||||
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
|
||||
Modules. Cite pills, header anchors, and thinking-small variants are
|
||||
intentionally absent (no matching DOM). Token names match that sheet. */
|
||||
|
||||
.markdown {
|
||||
@@ -160,6 +160,12 @@
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.markdown :global(.katex-display) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-label-secondary);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { isValidElement, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
const streamingRemarkPlugins = [remarkGfm]
|
||||
const settledRemarkPlugins = [remarkGfm, remarkMath]
|
||||
const settledRehypePlugins = [rehypeKatex]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
@@ -88,12 +93,12 @@ const streamingComponents = buildComponents(true)
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap);
|
||||
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
|
||||
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* component table memoizes on its identity and a fresh literal per render
|
||||
* would rebuild it every streaming chunk.
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
@@ -109,7 +114,8 @@ export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
|
||||
rehypePlugins={streaming ? undefined : settledRehypePlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
|
||||
@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
|
||||
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards localized labels to fenced code blocks', () => {
|
||||
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
|
||||
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
@@ -121,6 +126,39 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(screen.getByText('**unfinished')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
|
||||
const source = [
|
||||
'Einstein wrote $E = mc^2$.',
|
||||
'',
|
||||
'$$',
|
||||
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
|
||||
'$$',
|
||||
'',
|
||||
'$\\href{javascript:alert(1)}{unsafe}$',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={source} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(3)
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
|
||||
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
|
||||
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
|
||||
const live = render(<MarkdownText text={partial} streaming />)
|
||||
|
||||
expect(live.container.querySelector('.katex')).toBeNull()
|
||||
expect(live.container.querySelector('.katex-error')).toBeNull()
|
||||
expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
|
||||
|
||||
live.rerender(<MarkdownText text={complete} />)
|
||||
expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
|
||||
expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(live.container.querySelector('.katex-error')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('JsonBlock', () => {
|
||||
|
||||
@@ -46,7 +46,10 @@ async function composed(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
|
||||
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
|
||||
type Tail = 'none' | 'open' | 'aborted'
|
||||
|
||||
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -56,12 +59,13 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (openTail) {
|
||||
if (tail !== 'none') {
|
||||
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
@@ -92,7 +96,7 @@ describe('sessions.fork', () => {
|
||||
|
||||
it('uses the last completed turn only for omitted and past-end anchors', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-tail', 2, true)
|
||||
const source = liveAgent(ctx, 'session-tail', 2, 'open')
|
||||
const proxy = api(ctx)
|
||||
const expectedTypes = [
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
@@ -114,9 +118,26 @@ describe('sessions.fork', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('cuts through an aborted turn: stopped is closed, not open', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
|
||||
// What a stopped message's fork button anchors on: the frozen node sits
|
||||
// one event before its turn/end, floored client-side to that event's seq.
|
||||
const anchor = (source.events.at(-1)?.seq ?? 0) - 1
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) return
|
||||
expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'turn/start', 'user/message', 'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an in-log anchor whose turn is still open', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-open', 1, true)
|
||||
const source = liveAgent(ctx, 'session-open', 1, 'open')
|
||||
const anchor = source.events.at(-1)?.seq ?? 0
|
||||
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
|
||||
expect(response.result).toMatchObject({
|
||||
|
||||
@@ -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/ui/app-boot/README.md
|
||||
README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3
|
||||
README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c
|
||||
README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e
|
||||
README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5
|
||||
|
||||
@@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
@@ -480,11 +480,11 @@ export async function boot(
|
||||
export const HARNESS_SOURCE_SECTION = 'harness:source'
|
||||
|
||||
/**
|
||||
* Add a global prompt section naming the on-disk path to the harness source
|
||||
* checkout the running bin was launched from, so the agent knows where its own
|
||||
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
|
||||
* it). Call once on the settled boot context ({@link boot}); the section orders
|
||||
* just after the harness identity opener (`-100`) and before the deployment
|
||||
* Add a global prompt section naming the on-disk harness source checkout while
|
||||
* explicitly distinguishing it from the task workspace and current working
|
||||
* directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
|
||||
* checkout. Call once on the settled boot context ({@link boot}); the section
|
||||
* orders just after the harness identity opener (`-100`) and before the deployment
|
||||
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
|
||||
* augment, so this is then a no-op that returns `undefined`. The section is
|
||||
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
|
||||
@@ -499,6 +499,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() =
|
||||
return systemPrompt.section({
|
||||
name: HARNESS_SOURCE_SECTION,
|
||||
order: -99,
|
||||
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
|
||||
text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -391,9 +391,9 @@ describe('boot', () => {
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
|
||||
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
|
||||
const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
|
||||
|
||||
it('adds the source path between the harness identity and the deployment persona', async () => {
|
||||
it('distinguishes the source path from the current workdir between identity and persona', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
|
||||
|
||||
@@ -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/ui/tui/README.md
|
||||
README.md: 20ad471faa707cce0e6f0ce4df2289a900f09b93
|
||||
README.zh.md: a7d918ba3239d3e6feaa2fb43181e4bcf33e1d89
|
||||
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
|
||||
README.zh.md: 01055619f4df460284564f0a1816de366d809e01
|
||||
|
||||
@@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export interface Config extends TuiConfig {
|
||||
/**
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
|
||||
* leaves the first turn to the user.
|
||||
*/
|
||||
initialSkill?: string
|
||||
|
||||
@@ -227,7 +227,7 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh experimental-upgrade`
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade`
|
||||
* guided-session entry. The launcher sets it only when minting a fresh session,
|
||||
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
*/
|
||||
@@ -1690,7 +1690,7 @@ export function createTuiChat(
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`):
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`):
|
||||
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
|
||||
// chat is live and the agent is idle. The launcher sets this only for a fresh
|
||||
// session, so there is no prior turn to collide with; invokeSkill reports an
|
||||
|
||||
Reference in New Issue
Block a user