diff --git a/README.md b/README.md
index dcf2339..c132cf5 100644
--- a/README.md
+++ b/README.md
@@ -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.

diff --git a/docs/compatibility.md b/docs/compatibility.md
index 1d03379..4a09628 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -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!]
diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx
index ccfc588..8a193ef 100644
--- a/src/client/components/settingsCard.tsx
+++ b/src/client/components/settingsCard.tsx
@@ -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 (
+
+ {props.label}
+
+ )
+}
+
/** 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) }}
/>
+ {t('settings.promptHint')}
+ { set?.('promptPrefix', text) }}
+ />
+ { set?.('promptSuffix', text) }}
+ />
>
)
}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 1effef1..7d2339c 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -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',
diff --git a/src/client/settings.ts b/src/client/settings.ts
index 42605f2..54e59ff 100644
--- a/src/client/settings.ts
+++ b/src/client/settings.ts
@@ -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 }
diff --git a/src/client/styles/settings.css b/src/client/styles/settings.css
index fd8f630..299d6a6 100644
--- a/src/client/styles/settings.css
+++ b/src/client/styles/settings.css
@@ -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); }
diff --git a/src/host/config.ts b/src/host/config.ts
index daba31a..aec0f02 100644
--- a/src/host/config.ts
+++ b/src/host/config.ts
@@ -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 {
diff --git a/src/host/index.ts b/src/host/index.ts
index 8030fa3..a422f96 100644
--- a/src/host/index.ts
+++ b/src/host/index.ts
@@ -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'
diff --git a/src/host/promptInjection.ts b/src/host/promptInjection.ts
new file mode 100644
index 0000000..d385fd7
--- /dev/null
+++ b/src/host/promptInjection.ts
@@ -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,
+ })
+ })
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 0b9e6e0..efb5f5e 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -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
diff --git a/tests/baselines.ts b/tests/baselines.ts
index fabf038..2f87ae3 100644
--- a/tests/baselines.ts
+++ b/tests/baselines.ts
@@ -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)',
+ },
}
}
diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts
index db21edc..a729b64 100644
--- a/tests/client/components/settingsCard.spec.ts
+++ b/tests/client/components/settingsCard.spec.ts
@@ -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 {
- 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(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(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(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(m.container, '.lc-settings-select').every(s => s.disabled))
+ assert.ok(queryAll(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(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(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(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(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')
diff --git a/tests/client/helpers/kit.ts b/tests/client/helpers/kit.ts
index 700ba5d..18e9f6d 100644
--- a/tests/client/helpers/kit.ts
+++ b/tests/client/helpers/kit.ts
@@ -118,6 +118,17 @@ export async function click(el: HTMLElement): Promise {
})
}
+/** 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 {
+ 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 {
diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts
index 682bfe0..7b68d8f 100644
--- a/tests/client/index.spec.ts
+++ b/tests/client/index.spec.ts
@@ -390,6 +390,7 @@ describe('client entry: configForms inject', () => {
useContextSettings: (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()
diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts
index 49d0d9c..9cb3170 100644
--- a/tests/client/settings.spec.ts
+++ b/tests/client/settings.spec.ts
@@ -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', () => {
diff --git a/tests/compat/matrix.spec.ts b/tests/compat/matrix.spec.ts
index 8d45a5a..d5c0754 100644
--- a/tests/compat/matrix.spec.ts
+++ b/tests/compat/matrix.spec.ts
@@ -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))
diff --git a/tests/host/config.spec.ts b/tests/host/config.spec.ts
index 61f89cd..81b20ae 100644
--- a/tests/host/config.spec.ts
+++ b/tests/host/config.spec.ts
@@ -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.')
})
})
diff --git a/tests/host/promptInjection.spec.ts b/tests/host/promptInjection.spec.ts
new file mode 100644
index 0000000..4d18da6
--- /dev/null
+++ b/tests/host/promptInjection.spec.ts
@@ -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 = { 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))
+ })
+})