feat: system prompt prefix/suffix settings joined into every session

Two new per-user preferences, `promptPrefix`/`promptSuffix`, hold free text
joined at the very start / end of the harness's assembled system prompt for
all agents (subagents included) — a per-deployment persona or behavioral rule
riding the harness's own prompt registry instead of session-log surgery:

- src/host/promptInjection.ts registers two global prompt sections through a
  deferred `ctx.inject(['systemPrompt'], …)` (a composition without the
  registry degrades to a no-op; the registration unwinds with this plugin's
  fiber). Orders anchor on the harness's own section-order registry, so a
  central renumbering keeps the prefix first and the suffix last. Text is
  registered `interpolate: false`, so `{{...}}` in user text stays literal.
- The section text providers read the volatile config references the loader
  rewrites in place on preference edits, so an edit applies from the next
  turn without remounting the entry; new sessions carry the text from their
  first turn through the harness's own system-prompt projection.
- The settings card grows two free-text rows (local draft, committed on blur
  and on unmount; a host push never clobbers in-progress typing), zh/en
  strings, and the served-value sanitize/degrade path for the string fields.

Verified against a running harness: the committed text persists in the
profile layer, and a new session's system prompt starts with the prefix and
ends with the suffix. docs/compatibility.md documents the seams, and the
compat matrix probes them on both supported lines.
This commit is contained in:
bowenliang123 committed 2026-10-11 13:55:14 +08:00
1 parent 425fd2e7b8
commit d75b539419
18 files changed
+463 -13

No files matched your search

+3 -1
View File
@@ -51,7 +51,7 @@ Five surfaces, one story — what your agent is carrying, how it got there, and
| **Context tab** | The full dashboard: stats, composition, per-request trend, events, and file activity — in every session. |
| **Fleet tab** | The agent network: the current agent, its parents, and every subagent, live. |
| **`/context` command** | A centered modal with the same composition and context browser, without leaving the chat. |
| **Preferences card** | Per-user defaults: view placement, trend granularity & mode, File Activity sort, and more. |
| **Preferences card** | Per-user defaults: view placement, trend granularity & mode, File Activity sort, and the system prompt prefix/suffix joined around every session. |
## 🗂️ The Context Insights page
@@ -187,6 +187,8 @@ A centered dialog opens with the **Current Composition** card and the **Context
The **Context** preferences card holds this plugin's per-user settings — default placement (the **Context** and **Fleet** conversation tabs, the right Sidebar panel, or both), the **Fleet** tab's own Show/Hide switch, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. It lives on the **Plugins** page: the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form).
The **System Prompt · Prefix / Suffix** rows hold free text joined at the very start / end of every session's system prompt, for all agents (subagents included) — a per-deployment persona or behavioral rule that rides the harness's own prompt assembly. Edits apply from the next turn; leave a row empty to disable it.
In-chart and in-card toggles stay per-view and never overwrite the stored preference.
![The Context settings card](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/settings.png)
+5 -1
View File
@@ -51,8 +51,12 @@ The fold never branches on a detected harness version. The version probe is best
- **Developer messages**: `developer/message` tool-registry changes are nested under `data.message` and fold into injected context, including request composition, live surface, and removed-node archives. They never count as human inputs; empty-content events keep their surface position at zero tokens, matching the harness's message projection (its `deriveEventMessage` projects an empty message to null — the fold's structural read keeps the node).
- **Tool-result error mark**: the event-level `data.error` metadata is admitted only alongside the message mark (the generation's own validation refuses a row whose `data.error` outlives `message.isError`), so the fold reads `message.isError` alone; a hostile row carrying only the envelope field degrades to unflagged, never to a throw.
- **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice.
- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the nine preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition).
- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition).
- Preference values persisted on a retired line (the `settings.yaml` section `dsh-context`) are moved into the profile entry of the same name by the harness's own legacy import on first boot, so existing values carry into the current surface.
- **System-prompt prefix/suffix injection**: the `promptPrefix`/`promptSuffix` preferences are joined around the harness's assembled system prompt by two globally registered prompt sections (`src/host/promptInjection.ts`), not by string-editing session events. The seams this rides, probed per supported line in the compat matrix:
- `ctx.systemPrompt.section()` — the ordered-section registry face (`packages/core/system-prompt`). The sections are registered through a deferred `ctx.inject(['systemPrompt'], …)`, so a composition that starts the registry later still gets them, one without it degrades to a no-op, and the registration unwinds with this plugin's fiber. The orders anchor on the harness's own registry (`HARNESS_IDENTITY - 1000`, `DEPLOYMENT_PERSONA_SUFFIX + 1000`), keeping the prefix first and the suffix last even if the central order table is renumbered.
- The volatile-only commit — `vendor/loader/src/config/entry.ts` rewrites the running config references on a preference edit without remounting the entry. The section text providers read those references per assembly, so an edit applies from the next model step; the harness's own system-prompt projection then updates the session log (`system/message` replace or append, per its routing), which is how a new session carries the text from its first turn.
- Scope: global sections participate in every assembly — every session, subagents included (the same semantics as the harness's own deployment `personaPrefix`/`personaSuffix`). A deployment that registers a `complete` prompt section keeps ONLY that section, by the registry's design; the configured text is dropped there. The text is registered `interpolate: false`, so `{{...}}` in user text stays literal and can never trip the strict variable interpolation.
## Parsing resilience [IMPORTANT!]
+59 -1
View File
@@ -1,7 +1,7 @@
/** The dsh-context preference card, keyed on the Host-served `dsh-context` namespace: while that namespace is
* unavailable (a deployment without the Host half, or a remote browser) the card renders nothing. */
import { useState, type ReactElement } from 'react'
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDown } from '../primitives'
import type { SettingsField, SettingsState } from '../settings'
@@ -54,6 +54,49 @@ function PrefRow(props: PrefRowProps): ReactElement {
type Translate = ViewKit['t']
interface PromptTextRowProps {
label: string
placeholder: string
value: string
disabled: boolean
onCommit: (text: string) => void
}
/** One free-text preference row: a local draft committed on blur (and on an
* unmount without a prior blur), so typing never writes the wire per keystroke. */
function PromptTextRow(props: PromptTextRowProps): ReactElement {
const [draft, setDraft] = useState(props.value)
const [focused, setFocused] = useState(false)
const latest = useRef({ draft: props.value, value: props.value, commit: props.onCommit })
latest.current = { draft, value: props.value, commit: props.onCommit }
// Follow the served value only while the row is idle: a host push (or the
// optimistic echo) must never clobber in-progress typing.
if (!focused && draft !== props.value) setDraft(props.value)
useEffect(() => () => {
const { draft: text, value, commit } = latest.current
if (text !== value) commit(text)
}, [])
const rows = Math.min(8, Math.max(2, draft.split('\n').length))
return (
<div className="lc-settings-row lc-settings-textrow">
<span className="lc-settings-label">{props.label}</span>
<textarea
className="lc-settings-textarea"
rows={rows}
placeholder={props.placeholder}
disabled={props.disabled}
value={draft}
onFocus={() => { setFocused(true) }}
onBlur={() => {
setFocused(false)
if (draft !== props.value) props.onCommit(draft)
}}
onChange={(event) => { setDraft(event.target.value) }}
/>
</div>
)
}
/** The three on/off gates (entry visibility, the Fleet tab, the trend duration curve) share one row shape. */
function ShowHideRow(props: { t: Translate; label: string; value: string; disabled: boolean; onPick: (id: string) => void }): ReactElement {
return (
@@ -144,6 +187,21 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti
]}
onPick={(id) => { set?.('defaultFileSort', id) }}
/>
<p className="lc-settings-note">{t('settings.promptHint')}</p>
<PromptTextRow
label={t('settings.promptPrefix')}
placeholder={t('settings.promptPlaceholder')}
value={state.promptPrefix}
disabled={disabled}
onCommit={(text) => { set?.('promptPrefix', text) }}
/>
<PromptTextRow
label={t('settings.promptSuffix')}
placeholder={t('settings.promptPlaceholder')}
value={state.promptSuffix}
disabled={disabled}
onCommit={(text) => { set?.('promptSuffix', text) }}
/>
</>
)
}
+8
View File
@@ -100,6 +100,10 @@ export const DICT_ZH = {
'settings.toolSort': '工具定义 · 默认排序',
'settings.fileSort': '文件活动 · 默认排序',
'settings.readOnly': '当前环境的设置为只读',
'settings.promptPrefix': '系统提示词 · 前缀',
'settings.promptSuffix': '系统提示词 · 后缀',
'settings.promptHint': '拼接在所有会话系统提示词的最前/最后,对所有代理(含子代理)生效;修改从下一轮对话开始应用',
'settings.promptPlaceholder': '留空则不启用',
'gran.total': '全量',
'gran.delta': '增量',
'gran.modeHint': '全量:累计构成;增量:相对上一条请求的变化量',
@@ -637,6 +641,10 @@ export const DICT_EN = {
'settings.toolSort': 'Tool Schemas · Default sort',
'settings.fileSort': 'File Activity · Default sort',
'settings.readOnly': 'Settings are read-only in this environment',
'settings.promptPrefix': 'System Prompt · Prefix',
'settings.promptSuffix': 'System Prompt · Suffix',
'settings.promptHint': 'Joined at the very start/end of every session\'s system prompt, for all agents (subagents included); edits apply from the next turn',
'settings.promptPlaceholder': 'Leave empty to disable',
'gran.total': 'Total',
'gran.delta': 'Delta',
'gran.modeHint': 'Total: cumulative makeup; Delta: change vs the previous request',
+9 -1
View File
@@ -31,6 +31,8 @@ export interface SettingsState {
insightsEntry: InsightsEntry
durationCurve: DefaultDurationCurve
fleetTab: FleetTab
promptPrefix: string
promptSuffix: string
writable: boolean
}
@@ -61,6 +63,8 @@ type Prefs = {
insightsEntry?: InsightsEntry
durationCurve?: DefaultDurationCurve
fleetTab?: FleetTab
promptPrefix?: string
promptSuffix?: string
}
function prefsOf(value: unknown): Prefs {
@@ -76,11 +80,13 @@ function prefsOf(value: unknown): Prefs {
...(v.insightsEntry === 'show' || v.insightsEntry === 'hide' ? { insightsEntry: v.insightsEntry } : {}),
...(v.defaultDurationCurve === 'show' || v.defaultDurationCurve === 'hide' ? { durationCurve: v.defaultDurationCurve } : {}),
...(v.fleetTab === 'show' || v.fleetTab === 'hide' ? { fleetTab: v.fleetTab } : {}),
...(typeof v.promptPrefix === 'string' ? { promptPrefix: v.promptPrefix } : {}),
...(typeof v.promptSuffix === 'string' ? { promptSuffix: v.promptSuffix } : {}),
}
}
export function createContextSettings(): ContextSettings {
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', fleetTab: 'show', writable: false }
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', fleetTab: 'show', promptPrefix: '', promptSuffix: '', writable: false }
let scope: SettingsScopeLike | undefined
const listeners = new Set<() => void>()
/** Every field is a scalar, so a shallow compare is the store's identity check — and a new preference
@@ -111,6 +117,8 @@ export function createContextSettings(): ContextSettings {
insightsEntry: prefs.insightsEntry ?? (raw?.insightsEntry === undefined ? state.insightsEntry : 'show'),
durationCurve: prefs.durationCurve ?? (raw?.defaultDurationCurve === undefined ? state.durationCurve : 'show'),
fleetTab: prefs.fleetTab ?? (raw?.fleetTab === undefined ? state.fleetTab : 'show'),
promptPrefix: prefs.promptPrefix ?? (raw?.promptPrefix === undefined ? state.promptPrefix : ''),
promptSuffix: prefs.promptSuffix ?? (raw?.promptSuffix === undefined ? state.promptSuffix : ''),
writable: snap.writable,
})
return { placement: prefs.placement, insightsEntry: prefs.insightsEntry, fleetTab: prefs.fleetTab }
+6
View File
@@ -4,3 +4,9 @@
.lc-settings-label { flex: 1; min-width: 0; font-size: 14px; color: var(--dsw-alias-label-primary); }
.lc-settings-select { display: inline-flex; align-items: center; gap: 12px; height: 36px; padding: 0 14px; border: none; border-radius: 18px; background: var(--dsw-alias-bg-module-platform); font: inherit; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-settings-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.5; margin: 12px 0 4px; }
/* The text rows fold the label above the control (a wide row would crush under pane-width pressure);
control styling mirrors the harness's own ConfigField textarea tokens. */
.lc-settings-textrow { flex-direction: column; align-items: stretch; }
.lc-settings-textarea { width: 100%; box-sizing: border-box; padding: 6px 12px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; line-height: 1.5; resize: vertical; }
.lc-settings-textarea:disabled { opacity: 0.5; cursor: default; }
.lc-settings-textarea::placeholder { color: var(--dsw-alias-label-tertiary); }
+3
View File
@@ -56,6 +56,9 @@ export const Config = z.object({
insightsEntry: volatileField(z.union(['show', 'hide']).default('show').loose()),
defaultDurationCurve: volatileField(z.union(['show', 'hide']).default('show').loose()),
fleetTab: volatileField(z.union(['show', 'hide']).default('show').loose()),
// Loose: a stale persisted value (or a non-string) degrades to the empty default instead of failing the entry.
promptPrefix: volatileField(z.string().default('').loose()),
promptSuffix: volatileField(z.string().default('').loose()),
})
export function resolveBounds(config: Config | undefined): FoldBounds {
+2
View File
@@ -22,6 +22,7 @@ import { watchDetailChannel } from './detail'
import { createFallbackActivityDefinition, createFallbackHeadersDefinition, createFallbackTimelineDefinition } from './fallback'
import { watchFleetChannel } from './fleet'
import { createContextHeadersDefinition } from './headers'
import { watchPromptInjection } from './promptInjection'
import { watchStepIdentity } from './stepIdentity'
import { watchSkillCatalog } from './skills'
import { createContextTimelineDefinition } from './timeline'
@@ -65,6 +66,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name)))
ctx.sessionProjections.register(createContextActivityDefinition())
watchActivityBackfill(ctx, coldReads)
watchPromptInjection(ctx, config)
}
export type { Category, ContextEventRecord, RequestRecord, Snapshot, ContextTimeline, SurfaceNode } from '../shared/types'
+81
View File
@@ -0,0 +1,81 @@
/**
* dsh-context — system-prompt injection (host half).
*
* Joins the configured prefix/suffix text around the harness's assembled system
* prompt: two globally registered prompt sections (`ctx.systemPrompt.section`)
* whose text providers read the volatile config references the loader updates
* in place, so a preference edit applies from the next model step without
* remounting the entry. The harness assembles the prompt per step and projects
* the rendered text into the session log, so every new session carries the
* text from its first turn, and an edit rewrites an open session's effective
* prompt through the harness's own projection.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Config } from './config'
/** The `ctx.systemPrompt` face consumed here; feature-detected so a composition
* without the prompt-registry service degrades to a no-op. */
export interface SystemPromptFace {
section(section: {
name: string
order: number
text: string | ((context: unknown) => string)
interpolate?: boolean
}): () => void
getSectionOrder(name: string): number
}
/** The live text behind one config field: a volatile-marked field resolves to a
* `{ get }` reference the loader rewrites on preference edits; below the
* volatile seam the value is the plain stored string. */
function liveText(value: unknown): () => string {
if (value !== null && typeof value === 'object' && typeof (value as { get?: unknown }).get === 'function') {
const get = (value as { get: () => unknown }).get
return () => {
const text = get()
return typeof text === 'string' ? text : ''
}
}
const text = typeof value === 'string' ? value : ''
return () => text
}
/** The section text provider: whitespace-only text is disabled (the harness's
* renderPrompt drops empty sections), any other text stays byte-exact. */
function sectionText(read: () => string): (context: unknown) => string {
return () => {
const text = read()
return text.trim().length === 0 ? '' : text
}
}
/** Fixed clearance from the nearest harness-owned order anchor, outside every allocated slot. */
const ORDER_CLEARANCE = 1000
export function watchPromptInjection(ctx: Context, config: Config): void {
// Deferred inject: fires wherever the prompt registry exists (before or after
// this entry mounts); a composition without it leaves the child pending and
// this entry a no-op — the same optional-service idiom as the client half.
ctx.inject(['systemPrompt'] as never, (inner: Context) => {
const systemPrompt = (inner as { systemPrompt?: SystemPromptFace | null }).systemPrompt
if (systemPrompt === null || systemPrompt === undefined
|| typeof systemPrompt.section !== 'function'
|| typeof systemPrompt.getSectionOrder !== 'function') return
systemPrompt.section({
name: 'dsh-context:prompt-prefix',
// Ahead of every allocated slot, anchored through the harness's own order
// registry so a central renumbering keeps the prefix first.
order: systemPrompt.getSectionOrder('HARNESS_IDENTITY') - ORDER_CLEARANCE,
text: sectionText(liveText(config.promptPrefix)),
// Literal text: a user's `{{...}}` must never reach the strict variable interpolation.
interpolate: false,
})
systemPrompt.section({
name: 'dsh-context:prompt-suffix',
order: systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_SUFFIX') + ORDER_CLEARANCE,
text: sectionText(liveText(config.promptSuffix)),
interpolate: false,
})
})
}
+4
View File
@@ -175,6 +175,10 @@ export interface PluginSettings {
insightsEntry: InsightsEntry
defaultDurationCurve: DefaultDurationCurve
fleetTab: FleetTab
/** Free text joined ahead of the harness's assembled system prompt; empty disables. */
promptPrefix: string
/** Free text joined behind the harness's assembled system prompt; empty disables. */
promptSuffix: string
}
export type SettingsField = keyof PluginSettings
+19
View File
@@ -163,6 +163,18 @@ export interface Baseline {
/** The provider sources carrying the plugin id and the section shape the fold matches. */
providerFiles: readonly { file: string; needles: readonly string[] }[]
}
/** The system-prompt injection seam (src/host/promptInjection.ts): the
* prompt-registry service must expose the ordered-section face and the
* centrally owned order anchors the plugin reads, and the loader must
* commit volatile-only config edits into the running references, so a
* preference edit applies from the next model step without remounting. */
prompt: {
serviceFile: string
sectionNeedle: string
orderAnchors: readonly string[]
loaderVolatileFile: string
loaderVolatileNeedle: string
}
}
/** One support-matrix entry. Every supported line is the V4 generation, so
@@ -265,6 +277,13 @@ function baseline(id: BaselineId, tag: string, session: string): Baseline {
loopNeedles: ["'agent/pre-step'", "append('user/message'"],
prependProofFile: 'packages/context/time-context/src/index.ts',
},
prompt: {
serviceFile: 'packages/core/system-prompt/src/index.ts',
sectionNeedle: 'section(section: PromptSection)',
orderAnchors: ['HARNESS_IDENTITY', 'DEPLOYMENT_PERSONA_SUFFIX'],
loaderVolatileFile: 'vendor/loader/src/config/entry.ts',
loaderVolatileNeedle: 'updateVolatile(ref, source)',
},
}
}
+67 -5
View File
@@ -4,7 +4,7 @@ import { describe, test } from 'vitest'
import { makePluginConfigCard } from '../../../src/client/components/settingsCard'
import type { SettingsState } from '../../../src/client/settings'
import { DICT_EN } from '../../../src/client/i18n'
import { click, keydown, makeKit, mount, query, queryAll, text } from '../helpers/kit'
import { blur, click, focus, keydown, makeKit, mount, query, queryAll, text, typeText } from '../helpers/kit'
const kit = makeKit()
const PluginConfigCard = makePluginConfigCard(kit)
@@ -14,7 +14,7 @@ function hookFor(state: SettingsState) {
}
function stateOf(partial: Partial<SettingsState> = {}): SettingsState {
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', fleetTab: 'show', writable: true, ...partial }
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', fleetTab: 'show', promptPrefix: '', promptSuffix: '', writable: true, ...partial }
}
/** Menu items portaled into document.body while a select is open. */
@@ -33,14 +33,18 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
await m2.unmount()
})
test('renders the nine rows flat — no card chrome', async () => {
test('renders the nine selects and the two prompt text rows flat — no card chrome', async () => {
const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) }))
assert.equal(m.container.querySelector('.lc-settings-card'), null, 'no settings-section chrome')
assert.ok(query(m.container, '.lc-settings-prefs'))
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
assert.equal(selects.length, 9)
assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled')
assert.equal(m.container.querySelector('.lc-settings-note'), null)
const textareas = queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
assert.equal(textareas.length, 2)
assert.ok(textareas.every(t => t.disabled), 'loading is not ready: the text rows are disabled')
assert.ok(text(m.container).includes(DICT_EN['settings.promptHint']))
assert.equal(m.container.querySelector('[role="status"]'), null, 'no read-only notice while loading')
await m.unmount()
})
@@ -52,7 +56,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
}))
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
assert.ok(selects.every(s => !s.disabled))
assert.equal(m.container.querySelector('.lc-settings-note'), null)
assert.equal(m.container.querySelector('[role="status"]'), null, 'writable: no read-only notice')
assert.ok(text(m.container).includes(DICT_EN['settings.placement']))
assert.ok(text(selects[0]).includes(DICT_EN['placement.all']))
await click(selects[0])
@@ -220,9 +224,67 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
assert.equal(note.getAttribute('role'), 'status')
assert.equal(text(note), DICT_EN['settings.readOnly'])
assert.ok(queryAll<HTMLButtonElement>(m.container, '.lc-settings-select').every(s => s.disabled))
assert.ok(queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').every(t => t.disabled))
await m.unmount()
})
test('prompt text rows: typing stays local, the blur commits, an unmount without a blur commits too', async () => {
const calls: [string, string][] = []
const m = await mount(h(PluginConfigCard, {
useContextSettings: hookFor(stateOf()),
set: (field, value) => { calls.push([field, value]) },
}))
const textareas = queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
assert.ok(textareas.every(t => !t.disabled))
assert.equal(textareas[0].placeholder, DICT_EN['settings.promptPlaceholder'])
await focus(textareas[0])
await typeText(textareas[0], 'Be terse.')
assert.equal(textareas[0].value, 'Be terse.')
assert.deepEqual(calls, [], 'typing does not write the wire')
await blur(textareas[0])
assert.deepEqual(calls, [['promptPrefix', 'Be terse.']], 'the blur commits the draft')
await blur(textareas[1])
assert.deepEqual(calls, [['promptPrefix', 'Be terse.']], 'blurring an untouched row commits nothing')
await focus(textareas[1])
await typeText(textareas[1], 'Sign as Atlas.')
await m.unmount()
assert.deepEqual(calls, [['promptPrefix', 'Be terse.'], ['promptSuffix', 'Sign as Atlas.']], 'the unmount commits a pending draft')
})
test('prompt text rows: a committed draft is not re-committed; an idle row follows the served value; a focused row is never clobbered', async () => {
const calls: [string, string][] = []
const m = await mount(h(PluginConfigCard, {
useContextSettings: hookFor(stateOf({ promptPrefix: 'served' })),
set: (field, value) => { calls.push([field, value]) },
}))
const prefix = query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
assert.equal(prefix.value, 'served')
await focus(prefix)
await typeText(prefix, 'served!')
// A host push lands while the row is being edited.
await m.update(h(PluginConfigCard, {
useContextSettings: hookFor(stateOf({ promptPrefix: 'pushed' })),
set: (field, value) => { calls.push([field, value]) },
}))
assert.equal(query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').value, 'served!', 'the in-progress draft survives the push')
await blur(query(m.container, '.lc-settings-textarea'))
assert.deepEqual(calls, [['promptPrefix', 'served!']])
// Idle again: the next push is followed.
await m.update(h(PluginConfigCard, {
useContextSettings: hookFor(stateOf({ promptPrefix: 'pushed' })),
set: (field, value) => { calls.push([field, value]) },
}))
assert.equal(query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').value, 'pushed')
await m.unmount()
assert.deepEqual(calls, [['promptPrefix', 'served!']], 'no re-commit of an already served value')
})
test('a value matching no option falls back to the raw id; a missing set never throws', async () => {
const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ placement: 'weird' as never })) }))
const selects = queryAll(m.container, '.lc-settings-select')
+11
View File
@@ -118,6 +118,17 @@ export async function click(el: HTMLElement): Promise<void> {
})
}
/** Type into an input/textarea through act: the native value setter plus the
* input event React's onChange listens for (a plain assignment is swallowed
* by React's value tracker). */
export async function typeText(el: HTMLTextAreaElement | HTMLInputElement, text: string): Promise<void> {
await act(async () => {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(el, text)
el.dispatchEvent(new Event('input', { bubbles: true }))
})
}
/** Pointer hover. React 18 synthesizes onMouseEnter/onMouseLeave from the
* BUBBLING mouseover/mouseout pair — dispatching raw mouseenter/leave (they do not bubble) never reaches React's handlers. */
export async function hover(el: Element): Promise<void> {
+1
View File
@@ -390,6 +390,7 @@ describe('client entry: configForms inject', () => {
useContextSettings: <T,>(sel: (state: SettingsState) => T): T => sel(store.getSnapshot()),
}))
assert.equal(queryAll(m.container, '.lc-settings-select').length, 9)
assert.equal(queryAll(m.container, '.lc-settings-textarea').length, 2)
assert.equal(m.container.querySelector('.lc-settings-head'), null, 'no settings-section chrome on this seat')
await m.unmount()
disposeRegistration()
+26 -1
View File
@@ -53,6 +53,8 @@ describe('createContextSettings defaults', () => {
insightsEntry: 'show',
durationCurve: 'show',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
@@ -90,6 +92,8 @@ describe('set', () => {
assert.equal(s.defaultPlacement(), 'sidebar')
s.set('defaultDeltaBase', 'turn')
assert.equal(s.defaultDeltaBase(), 'turn')
s.set('promptPrefix', 'Be terse.')
assert.equal(s.store.getSnapshot().promptPrefix, 'Be terse.')
})
test('an unchanged value does not notify listeners', () => {
@@ -207,6 +211,17 @@ describe('set', () => {
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.fleetTab(), 'hide')
})
test('a rejected prompt write rolls back to the scope truth', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { promptPrefix: 'kept' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('promptPrefix', 'draft')
assert.equal(s.store.getSnapshot().promptPrefix, 'draft', 'the optimistic echo lands first')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.store.getSnapshot().promptPrefix, 'kept', 'the echo rolls back to the scope truth')
})
})
describe('attach', () => {
@@ -224,6 +239,8 @@ describe('attach', () => {
insightsEntry: 'hide',
defaultDurationCurve: 'hide',
fleetTab: 'hide',
promptPrefix: 'Be terse.',
promptSuffix: 'Sign as Atlas.',
},
writable: true,
})
@@ -239,6 +256,8 @@ describe('attach', () => {
insightsEntry: 'hide',
durationCurve: 'hide',
fleetTab: 'hide',
promptPrefix: 'Be terse.',
promptSuffix: 'Sign as Atlas.',
writable: true,
})
})
@@ -272,6 +291,8 @@ describe('attach', () => {
insightsEntry: 'show',
durationCurve: 'show',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
writable: false,
})
}
@@ -281,7 +302,7 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42, defaultDurationCurve: 'always', fleetTab: 7 },
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42, defaultDurationCurve: 'always', fleetTab: 7, promptPrefix: 9, promptSuffix: {} },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
@@ -293,6 +314,8 @@ describe('attach', () => {
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.defaultDurationCurve(), 'show')
assert.equal(s.fleetTab(), 'show')
assert.equal(s.store.getSnapshot().promptPrefix, '', 'a non-string prompt value degrades to the empty default')
assert.equal(s.store.getSnapshot().promptSuffix, '')
})
test('an invalid scope placement degrades to all instead of keeping the current one', () => {
@@ -346,6 +369,7 @@ describe('attach', () => {
s.set('defaultToolSort', 'size')
s.set('insightsEntry', 'hide')
s.set('defaultDurationCurve', 'hide')
s.set('promptPrefix', 'drafted')
s.attach(new TestSettingsScope({ status: 'ready', value: { defaultFileSort: 'latest' }, writable: false }))
assert.equal(s.defaultPlacement(), 'tab', 'the in-session choice survives a section without the field')
assert.equal(s.defaultGranularity(), 'step')
@@ -354,6 +378,7 @@ describe('attach', () => {
assert.equal(s.defaultFileSort(), 'latest')
assert.equal(s.insightsEntry(), 'hide', 'the in-session entry choice survives a section without the field')
assert.equal(s.defaultDurationCurve(), 'hide', 'the in-session curve choice survives a section without the field')
assert.equal(s.store.getSnapshot().promptPrefix, 'drafted', 'the in-session prompt text survives a section without the field')
})
test('scope updates republish to subscribers', () => {
+22
View File
@@ -260,6 +260,28 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base
)
})
test('host: the system-prompt injection seam', () => {
// The prefix/suffix sections register through the prompt registry's
// ordered-section face, anchored on its centrally owned order names,
// and the preference edits ride the loader's volatile-only commit into
// the running references — a moved seam names itself here instead of
// silently dropping the configured text.
const prompt = baseline.prompt
assert.equal(
staging.dshHasString(baseline.tag, prompt.sectionNeedle, prompt.serviceFile),
true,
'the ordered-section face',
)
for (const anchor of prompt.orderAnchors) {
assert.equal(staging.dshHasString(baseline.tag, anchor, prompt.serviceFile), true, `order anchor: ${anchor}`)
}
assert.equal(
staging.dshHasString(baseline.tag, prompt.loaderVolatileNeedle, prompt.loaderVolatileFile),
true,
'the loader volatile-only commit',
)
})
test('client: platform module table answers every bundle require', async () => {
const table = await staging.platformModulesOf(baseline)
const unanswerable = staging.bundleRequires().filter(spec => !table.includes(spec))
+9 -3
View File
@@ -34,6 +34,8 @@ describe('Config validator (cordis Standard Schema face)', () => {
insightsEntry: 'show',
defaultDurationCurve: 'show',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
})
assert.deepEqual(resolveBounds(undefined), DEFAULT_BOUNDS)
assert.deepEqual(resolveBounds({}), DEFAULT_BOUNDS)
@@ -71,7 +73,7 @@ describe('Config validator (cordis Standard Schema face)', () => {
describe('preference fields (the Config-form generation surface)', () => {
test('every preference is volatile-marked; the bounds are not', () => {
const dict = Config.dict ?? {}
for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry', 'defaultDurationCurve', 'fleetTab']) {
for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry', 'defaultDurationCurve', 'fleetTab', 'promptPrefix', 'promptSuffix']) {
assert.equal((dict[pref] as z['dict'] extends undefined ? never : { meta?: { volatile?: boolean } }).meta?.volatile, true, pref)
}
for (const bound of ['maxRequestSteps', 'maxKeptTurns', 'maxEvents', 'maxNodes', 'maxArchiveNodes', 'maxFileOps']) {
@@ -80,7 +82,7 @@ describe('preference fields (the Config-form generation surface)', () => {
})
test('preferences resolve to schema defaults; a stale value degrades to its default (loose)', () => {
const resolved = Config({ defaultGranularity: 'bogus' } as unknown as Config)
const resolved = Config({ defaultGranularity: 'bogus', promptPrefix: 7, promptSuffix: {} } as unknown as Config)
assert.equal(deref(resolved.defaultPlacement), 'all')
assert.equal(deref(resolved.defaultGranularity), 'step')
assert.equal(deref(resolved.defaultTrendMode), 'total')
@@ -90,13 +92,17 @@ describe('preference fields (the Config-form generation surface)', () => {
assert.equal(deref(resolved.insightsEntry), 'show')
assert.equal(deref(resolved.defaultDurationCurve), 'show')
assert.equal(deref(resolved.fleetTab), 'show')
assert.equal(deref(resolved.promptPrefix), '', 'a non-string prompt value degrades to the empty default')
assert.equal(deref(resolved.promptSuffix), '')
})
test('a stored preference survives the resolve', () => {
const resolved = Config({ defaultPlacement: 'sidebar', insightsEntry: 'hide', fleetTab: 'hide' })
const resolved = Config({ defaultPlacement: 'sidebar', insightsEntry: 'hide', fleetTab: 'hide', promptPrefix: 'Be terse.', promptSuffix: 'Sign as Atlas.' })
assert.equal(deref(resolved.defaultPlacement), 'sidebar')
assert.equal(deref(resolved.insightsEntry), 'hide')
assert.equal(deref(resolved.fleetTab), 'hide')
assert.equal(deref(resolved.promptPrefix), 'Be terse.')
assert.equal(deref(resolved.promptSuffix), 'Sign as Atlas.')
})
})
+128
View File
@@ -0,0 +1,128 @@
// System-prompt injection (src/host/promptInjection.ts): the two globally registered
// prompt sections carrying the configured prefix/suffix, their live-text providers,
// and the feature-detect degradation when the harness prompt registry is absent.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { watchPromptInjection, type SystemPromptFace } from '../../src/host/promptInjection'
type RegisteredSection = {
name: string
order: number
text: string | ((context: unknown) => string)
interpolate?: boolean
}
/** A capturing prompt-registry face with the harness's allocated order anchors. */
function fakeSystemPrompt(): SystemPromptFace & { registered: RegisteredSection[] } {
const registered: RegisteredSection[] = []
return {
registered,
section(section: RegisteredSection): () => void {
registered.push(section)
return () => {}
},
getSectionOrder(name: string): number {
const orders: Record<string, number> = { HARNESS_IDENTITY: -1000, DEPLOYMENT_PERSONA_SUFFIX: 10200 }
return orders[name] ?? 0
},
}
}
/** A context whose deferred inject fires synchronously with the given service view. */
function ctxWith(systemPrompt: unknown): Context {
return {
inject: (_names: unknown, cb: (inner: unknown) => void) => { cb({ systemPrompt }) },
} as unknown as Context
}
/** A volatile config reference stand-in over a mutable box. */
function ref(box: { value: unknown }): { get: () => unknown } {
return { get: () => box.value }
}
function textOf(section: RegisteredSection | undefined): string {
return typeof section?.text === 'function' ? section.text(undefined) : ''
}
describe('watchPromptInjection', () => {
test('registers both sections outside every allocated slot, with literal text', () => {
const face = fakeSystemPrompt()
watchPromptInjection(ctxWith(face), { promptPrefix: 'FIRST', promptSuffix: 'LAST' } as never)
assert.deepEqual(face.registered.map(section => section.name), [
'dsh-context:prompt-prefix',
'dsh-context:prompt-suffix',
])
const [prefix, suffix] = face.registered
assert.equal(prefix.order, -2000, 'ahead of HARNESS_IDENTITY, anchored through the harness registry')
assert.equal(suffix.order, 11200, 'behind DEPLOYMENT_PERSONA_SUFFIX')
assert.equal(textOf(prefix), 'FIRST')
assert.equal(textOf(suffix), 'LAST')
assert.ok(face.registered.every(section => section.interpolate === false), 'the text is literal, never variable-interpolated')
})
test('a volatile config reference is read at each assembly; a live edit applies', () => {
const face = fakeSystemPrompt()
const box = { value: 'before' as unknown }
watchPromptInjection(ctxWith(face), { promptPrefix: ref(box), promptSuffix: '' } as never)
const prefix = face.registered[0]
assert.equal(textOf(prefix), 'before')
box.value = 'after'
assert.equal(textOf(prefix), 'after', 'the loader rewrites the reference in place; the next assembly reads it')
})
test('a plain string config stays static; non-string values read as empty', () => {
const face = fakeSystemPrompt()
watchPromptInjection(ctxWith(face), { promptPrefix: 'static', promptSuffix: 42 } as never)
const [prefix, suffix] = face.registered
assert.equal(textOf(prefix), 'static')
assert.equal(textOf(suffix), '')
const refFace = fakeSystemPrompt()
const box = { value: 7 }
watchPromptInjection(ctxWith(refFace), { promptPrefix: ref(box), promptSuffix: ref(box) } as never)
assert.equal(textOf(refFace.registered[0]), '', 'a reference carrying a non-string reads as empty')
})
test('whitespace-only text collapses to empty; other text stays byte-exact', () => {
const face = fakeSystemPrompt()
watchPromptInjection(ctxWith(face), { promptPrefix: ' \n\t ', promptSuffix: '\nline one\n\nline two\n' } as never)
const [prefix, suffix] = face.registered
assert.equal(textOf(prefix), '', 'a blank prompt contributes nothing')
assert.equal(textOf(suffix), '\nline one\n\nline two\n', 'the configured bytes are preserved')
})
test('an absent or malformed prompt registry degrades to a no-op', () => {
assert.doesNotThrow(() => watchPromptInjection(ctxWith(undefined), {} as never))
assert.doesNotThrow(() => watchPromptInjection(ctxWith({}), {} as never))
assert.doesNotThrow(() => watchPromptInjection(ctxWith({ section: () => {} }), {} as never))
assert.doesNotThrow(() => watchPromptInjection(ctxWith({ getSectionOrder: () => 0 }), {} as never))
assert.doesNotThrow(() => watchPromptInjection(ctxWith(null), {} as never))
})
test('the deferred inject rides the real cordis: early, late, and never-provided compositions', async () => {
const { Context } = await import('@deepseek-ai/cordis')
// Provided before this entry mounts.
const early = new Context()
early.provide('systemPrompt', fakeSystemPrompt())
await new Promise(resolve => setTimeout(resolve, 10))
watchPromptInjection(early, { promptPrefix: 'P' } as never)
await new Promise(resolve => setTimeout(resolve, 10))
assert.equal((early.reflect.get('systemPrompt') as unknown as { registered: RegisteredSection[] }).registered.length, 2, 'the service first: both sections register')
// Provided after this entry mounts (a composition that starts later).
const late = new Context()
watchPromptInjection(late, { promptPrefix: 'P' } as never)
await new Promise(resolve => setTimeout(resolve, 10))
const lateFace = fakeSystemPrompt()
late.provide('systemPrompt', lateFace)
await new Promise(resolve => setTimeout(resolve, 10))
assert.equal(lateFace.registered.length, 2, 'the service later: the pending inject fires on the provide')
// Never provided: no throw, nothing registers.
const never = new Context()
assert.doesNotThrow(() => watchPromptInjection(never, {} as never))
await new Promise(resolve => setTimeout(resolve, 10))
})
})