diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 90544fbf787..706d3766b4f 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -945,15 +945,19 @@ presence metadata, startup state, and runtime state; literal secrets are never returned. Channel snapshots use `Cache-Control: no-store`. Field descriptors can expose nested object metadata through `properties`. -Numeric descriptors can use `exclusiveMinimum` for open lower bounds. Clients -that do not render an advertised field kind must preserve its existing config -value instead of coercing or deleting it. Object fields cannot be required, -and nested properties cannot be secrets or environment-resolvable fields; -those management protocols remain top-level only. A nested `required` property -is enforced only while its parent object is present in the write; omitting the -parent object leaves its nested requirements unchecked. Writes replace each -field's stored value wholesale, so preserving an object means resending the -stored object; the daemon does not merge partial objects. +Numeric descriptors can use `exclusiveMinimum` for open lower bounds. String +and secret descriptors can use `multiline` to ask clients for a multi-line text +area; the descriptor types allow it only on top-level fields. Clients that do +not render an advertised field kind must preserve its existing config value +instead of coercing or deleting it, and a client that renders a `multiline` +field in a single-line control must preserve the stored value verbatim instead +of writing back its newline-stripped input value. Object fields cannot be +required, and nested properties cannot be secrets or environment-resolvable +fields; those management protocols remain top-level only. A nested `required` +property is enforced only while its parent object is present in the write; +omitting the parent object leaves its nested requirements unchecked. Writes +replace each field's stored value wholesale, so preserving an object means +resending the stored object; the daemon does not merge partial objects. Configuration writes use optimistic concurrency and the strict operator-authority gate: diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index a88cd0ec137..fe452315e4d 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -454,6 +454,8 @@ export interface ChannelConfigValueFieldDescriptor kind: 'string' | 'secret'; required?: boolean; envResolvable?: boolean; + /** Render the field as a multi-line text area in management UIs. */ + multiline?: boolean; properties?: never; } @@ -462,6 +464,7 @@ export interface ChannelConfigPlainValueFieldDescriptor kind: 'boolean' | 'string-list' | 'record'; required?: boolean; envResolvable?: never; + multiline?: never; properties?: never; } @@ -470,6 +473,7 @@ export interface ChannelConfigEnumFieldDescriptor kind: 'enum'; required?: boolean; envResolvable?: never; + multiline?: never; options: ReadonlyArray<{ value: string; label: string }>; properties?: never; } @@ -479,6 +483,7 @@ export interface ChannelConfigNumberFieldDescriptor kind: 'number'; required?: boolean; envResolvable?: never; + multiline?: never; exclusiveMinimum?: number; properties?: never; } @@ -488,16 +493,21 @@ export interface ChannelConfigObjectFieldDescriptor kind: 'object'; required?: false; envResolvable?: never; + multiline?: never; properties: readonly ChannelConfigNestedFieldDescriptor[]; } export type ChannelConfigNestedFieldDescriptor = - | (Omit & { + | (Omit< + ChannelConfigValueFieldDescriptor, + 'kind' | 'envResolvable' | 'multiline' + > & { kind: Exclude< ChannelConfigFieldKind, 'secret' | 'enum' | 'number' | 'object' >; envResolvable?: never; + multiline?: never; }) | (Omit & { kind: 'enum'; diff --git a/packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts b/packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts index ca6209fe96b..c4555142dd0 100644 --- a/packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts +++ b/packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts @@ -83,6 +83,7 @@ function assertDescriptorWireShape( (descriptor.kind === 'string' || descriptor.kind === 'secret') ) { allowedKeys.add('envResolvable'); + allowedKeys.add('multiline'); } if (descriptor.kind === 'number') { allowedKeys.add('exclusiveMinimum'); diff --git a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts index d4c9e83104f..0712724481d 100644 --- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts +++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts @@ -127,9 +127,22 @@ describe('built-in channel registry', () => { 'groupPolicy', 'sessionScope', 'multiSession', + 'instructions', ]); expect( entry?.fields.find((field) => field.key === 'senderPolicy'), ).toMatchObject({ default: 'pairing' }); + // The shared descriptor is injected into every manageable channel, and + // dingtalk substitutes its own default block instead of composing with it + // (DingtalkAdapter.ts:908), so pin both the multiline render hint and the + // neutral copy: neither survives an accidental revert to a plain string + // field or to text that promises additive guidance. + const instructions = entry?.fields.find( + (field) => field.key === 'instructions', + ); + expect(instructions).toMatchObject({ kind: 'string', multiline: true }); + expect(instructions?.description).toContain( + 'replace their own default guidance', + ); }); }); diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 1c5bdb114eb..b864615558b 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -774,6 +774,35 @@ describe('channel registry', () => { .filter((entry) => entry.manageable) .map((entry) => entry.type), ).toEqual(['dingtalk', 'dws', 'wecom', 'feishu', 'github', 'gitlab']); + // The registry skips the shared `instructions` injection for any channel + // that declares its own, so pin the render invariants the editor depends on + // for every manageable built-in: exactly one field, plus the multiline hint + // (without it the editor falls back to a single-line input that flattens + // stored guidance on the first edit). The copy guarantee is scoped to the + // injected descriptor, because a channel declaring its own `instructions` + // takes the skip branch and may carry tailored neutral copy, and it is + // asserted where an operator actually reads it + // (ChannelEditorDialog.test.tsx): fieldDescription resolves + // `${labelKey}.description` and falls back to this literal only when that + // i18n key is missing. + for (const entry of builtinCatalog.filter((item) => item.manageable)) { + const instructions = entry.fields.filter( + (field) => field.key === 'instructions', + ); + expect(instructions).toHaveLength(1); + expect(instructions[0]).toMatchObject({ + kind: 'string', + multiline: true, + }); + const declaresOwnInstructions = ( + await getPlugin(entry.type) + )?.management?.fields?.some((field) => field.key === 'instructions'); + if (!declaresOwnInstructions) { + expect(instructions[0].description).toContain( + 'replace their own default guidance', + ); + } + } expect( catalog.find((entry) => entry.type === 'dingtalk')?.fields, ).toContainEqual( diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 9309f8f7f78..54e84e4d54d 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -122,6 +122,18 @@ function managementFieldsWithSharedControls( 'Retain an owner-scoped catalog of named tasks in daemon-managed mode', }, ]), + ...(declared.has('instructions') + ? [] + : [ + { + key: 'instructions', + label: 'Instructions', + kind: 'string' as const, + multiline: true, + description: + 'Guidance injected into each channel session context; some channels replace their own default guidance when this is set', + }, + ]), ]; } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index c57461ee3f1..a8067f298a7 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -4003,6 +4003,8 @@ export interface DaemonChannelConfigValueFieldDescriptor kind: 'string' | 'secret'; required?: boolean; envResolvable?: boolean; + /** Render the field as a multi-line text area in management UIs. */ + multiline?: boolean; properties?: never; } @@ -4011,6 +4013,7 @@ export interface DaemonChannelConfigPlainValueFieldDescriptor kind: 'boolean' | 'string-list' | 'record'; required?: boolean; envResolvable?: never; + multiline?: never; properties?: never; } @@ -4019,6 +4022,7 @@ export interface DaemonChannelConfigEnumFieldDescriptor kind: 'enum'; required?: boolean; envResolvable?: never; + multiline?: never; options: ReadonlyArray<{ value: string; label: string }>; properties?: never; } @@ -4028,6 +4032,7 @@ export interface DaemonChannelConfigNumberFieldDescriptor kind: 'number'; required?: boolean; envResolvable?: never; + multiline?: never; exclusiveMinimum?: number; properties?: never; } @@ -4037,16 +4042,21 @@ export interface DaemonChannelConfigObjectFieldDescriptor kind: 'object'; required?: false; envResolvable?: never; + multiline?: never; properties: readonly DaemonChannelConfigNestedFieldDescriptor[]; } export type DaemonChannelConfigNestedFieldDescriptor = - | (Omit & { + | (Omit< + DaemonChannelConfigValueFieldDescriptor, + 'kind' | 'envResolvable' | 'multiline' + > & { kind: Exclude< DaemonChannelConfigFieldKind, 'secret' | 'enum' | 'number' | 'object' >; envResolvable?: never; + multiline?: never; }) | (Omit & { kind: 'enum'; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index 2acb3014682..c594fa7fab4 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -156,6 +156,31 @@ const EXCLUSIVE_MINIMUM: DaemonChannelTypeDescriptor = { ], }; +// The shared `instructions` control the channel registry injects into every +// manageable channel (channel-registry.ts), plus a plain string sibling. The +// descriptor label and description for `instructions` intentionally differ from +// the i18n values so a missing i18n key surfaces the untranslated fallback +// instead of passing the assertions below. +const MULTILINE_INSTRUCTIONS: DaemonChannelTypeDescriptor = { + type: 'example', + displayName: 'Example', + manageable: true, + fields: [ + { + key: 'instructions', + label: 'Session instructions (descriptor)', + description: 'DESCRIPTOR FALLBACK COPY', + kind: 'string', + multiline: true, + }, + { + key: 'apiEndpoint', + label: 'API endpoint (descriptor)', + kind: 'string', + }, + ], +}; + const INSTANCE: DaemonChannelInstanceSnapshot = { name: 'release-bot', config: { @@ -202,12 +227,15 @@ const { I18nProvider } = await import('../../i18n'); let container: HTMLDivElement; let root: Root; -async function renderDialog( - props: Partial> = {}, -) { +async function renderDialog({ + language = 'en', + ...props +}: Partial> & { + language?: 'en' | 'zh-CN'; +} = {}) { await act(async () => { root.render( - + inside the field +// wrapper that also holds the control, after the label header. +function descriptionOf(element: HTMLElement | null): string { + return element?.parentElement?.querySelector('p')?.textContent?.trim() ?? ''; +} + beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); @@ -475,6 +525,135 @@ describe('ChannelEditorDialog', () => { expect(document.body.textContent).toContain('By user'); }); + it('renders a multiline string field as a textarea and leaves a plain one as an input', async () => { + await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS }); + + const instructions = fieldByLabel('Instructions'); + const endpoint = fieldByLabel('API endpoint (descriptor)'); + + expect(instructions?.tagName).toBe('TEXTAREA'); + expect(endpoint?.tagName).toBe('INPUT'); + }); + + it('groups the shared instructions control with conversation management', async () => { + await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS }); + + // `instructions` is not a credential: it is stored in clear text and + // injected into the session context, so it belongs with the other shared + // session controls rather than in the catch-all credentials panel. + expect(sectionHeadingOf(fieldByLabel('Instructions'))).toBe( + 'Conversation management', + ); + expect(sectionHeadingOf(fieldByLabel('API endpoint (descriptor)'))).toBe( + 'Credentials', + ); + }); + + it('renders the localized instructions copy above the textarea instead of the descriptor literal', async () => { + await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS }); + + // `instructions` is the only multiline field in the fixture, so the single + // textarea anchors the field. fieldDescription resolves + // `${labelKey}.description` and returns the i18n value whenever the key + // translates, so this — not the registry literal the catalog serves — is + // what an operator reads. It is also the only place the replace-not-append + // behaviour is documented: an additive promise would have a DingTalk + // operator save over the default identity block that DingtalkAdapter.ts + // installs only when config.instructions is falsy. + const description = descriptionOf(document.querySelector('textarea')); + expect(description).toContain('replace their own default guidance'); + expect(description).not.toContain('DESCRIPTOR FALLBACK COPY'); + }); + + it('localizes the instructions copy for zh-CN operators', async () => { + await renderDialog({ + descriptor: MULTILINE_INSTRUCTIONS, + language: 'zh-CN', + }); + + // getTranslator resolves `messages[key] ?? EN[key] ?? key`, so an assertion + // phrased only as "not the raw key", or as an English substring, still + // passes on the EN fallback once the ZH entry is deleted. `替换` occurs only + // in the ZH value, so this goes red on that mutation. + const description = descriptionOf(document.querySelector('textarea')); + expect(description).toContain('替换'); + expect(description).not.toContain('DESCRIPTOR FALLBACK COPY'); + }); + + it('saves a multi-line instructions value with the newline intact', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await renderDialog({ descriptor: MULTILINE_INSTRUCTIONS, onSave }); + + const instructions = fieldByLabel('Instructions'); + expect(instructions).toBeInstanceOf(HTMLTextAreaElement); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'release-bot'); + setTextareaValue( + instructions as HTMLTextAreaElement, + ' line one\nline two ', + ); + }); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + + // assignField trims the outer whitespace but must not flatten the + // embedded newline, or the control cannot carry multi-line guidance. + expect(onSave).toHaveBeenCalledWith( + 'release-bot', + expect.objectContaining({ + config: expect.objectContaining({ + instructions: 'line one\nline two', + }), + }), + ); + }); + + it('loads a stored multi-line instructions value into the textarea and saves it unchanged', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + // Edit mode: createChannelEditorDraft loads a stored string untrimmed + // (channel-editor-state.ts:110) while assignField trims on save + // (channel-editor-state.ts:249), so the fixture carries no outer + // whitespace and the round trip must be exact. Without the draft value + // threaded into the Textarea, an operator editing a configured channel + // sees an empty box and their first keystroke replaces the whole block. + await renderDialog({ + descriptor: MULTILINE_INSTRUCTIONS, + instance: { + ...INSTANCE, + config: { ...INSTANCE.config, instructions: 'line one\nline two' }, + }, + onSave, + }); + + const instructions = fieldByLabel('Instructions'); + expect(instructions).toBeInstanceOf(HTMLTextAreaElement); + expect((instructions as HTMLTextAreaElement).value).toBe( + 'line one\nline two', + ); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + + expect(onSave).toHaveBeenCalledWith( + 'release-bot', + expect.objectContaining({ + config: expect.objectContaining({ + instructions: 'line one\nline two', + }), + }), + ); + }); + it('submits a new instance with typed fields and the current revision', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await renderDialog({ onSave }); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 6a710cea6dc..72ed867832f 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -39,6 +39,7 @@ import { DialogTitle, } from '../ui/dialog'; import { Input } from '../ui/input'; +import { Textarea } from '../ui/textarea'; import { Label } from '../ui/label'; import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; import { @@ -102,7 +103,11 @@ const SHARED_ACCESS_FIELD_KEYS = new Set([ 'allowedUsers', 'groupPolicy', ]); -const SHARED_SESSION_FIELD_KEYS = new Set(['sessionScope', 'multiSession']); +const SHARED_SESSION_FIELD_KEYS = new Set([ + 'sessionScope', + 'multiSession', + 'instructions', +]); const SHARED_FIELD_LABEL_KEYS: Record = { senderPolicy: 'channels.editor.field.shared.senderPolicy', @@ -110,6 +115,7 @@ const SHARED_FIELD_LABEL_KEYS: Record = { groupPolicy: 'channels.editor.field.shared.groupPolicy', sessionScope: 'channels.editor.field.shared.sessionScope', multiSession: 'channels.editor.field.shared.multiSession', + instructions: 'channels.editor.field.shared.instructions', }; export interface ChannelEditorDialogProps { @@ -592,6 +598,26 @@ export function ChannelEditorDialog({ ); } + if (field.kind === 'string' && field.multiline) { + return ( + +