Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions kun/src/adapters/model/compat-message-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,39 @@ describe('compat composer context projection', () => {
])
})

it('keeps the stable prefix and history byte-identical when a persona changes', () => {
const requestFor = (personaBlock?: string): ModelRequest => ({
threadId: 'thread-persona',
turnId: 'turn-persona',
model: 'test-model',
systemPrompt: 'stable-system-prefix',
modeInstruction: 'mode-instruction',
prefix: [],
history: [makeUserItem({
id: 'item-persona',
threadId: 'thread-persona',
turnId: 'turn-persona',
text: 'user-history'
})],
...(personaBlock ? { contextInstructions: ['turn-context-preamble', personaBlock] } : {}),
tools: [],
abortSignal: new AbortController().signal
})
const project = (personaBlock?: string): Array<[string, unknown]> =>
projectCompatMessages(requestFor(personaBlock), {
thinkingMode: false,
supportsImages: false
}).map((message) => [message.role, message.content])

const withoutPersona = project()
const withPersona = project('<kun_context_block kind="persona" authority="user">skeptic</kun_context_block>')

// Everything the provider caches — prefix, mode, and history — is untouched;
// the persona only appends after it. This is what makes switching cheap.
expect(withPersona.slice(0, withoutPersona.length)).toEqual(withoutPersona)
expect(withPersona[withPersona.length - 1]?.[1]).toContain('kind="persona"')
})

it('projects durable goal context as history rather than a per-request instruction', () => {
const request: ModelRequest = {
threadId: 'thread-goal',
Expand Down
55 changes: 55 additions & 0 deletions kun/src/contracts/turns.persona.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { StartTurnRequest, TURN_PERSONA_MAX_CHARS } from './turns.js'
import { ThreadSchema } from './threads.js'
import { createTurnRecord } from '../domain/turn.js'

describe('StartTurnRequest.persona', () => {
it('accepts a persona within the cap', () => {
const parsed = StartTurnRequest.safeParse({ prompt: 'hi', persona: 'Be skeptical.' })
expect(parsed.success).toBe(true)
expect(parsed.success && parsed.data.persona).toBe('Be skeptical.')
})

it('is optional', () => {
const parsed = StartTurnRequest.safeParse({ prompt: 'hi' })
expect(parsed.success).toBe(true)
expect(parsed.success && parsed.data.persona).toBeUndefined()
})

it('rejects a persona over the cap so it cannot displace conversation context', () => {
const parsed = StartTurnRequest.safeParse({
prompt: 'hi',
persona: 'x'.repeat(TURN_PERSONA_MAX_CHARS + 1)
})
expect(parsed.success).toBe(false)
})
})

describe('createTurnRecord persona', () => {
const base = { id: 't1', threadId: 'th1', prompt: 'hi', model: 'm' }

it('persists a trimmed persona on the turn record', () => {
expect(createTurnRecord({ ...base, persona: ' Be terse. ' }).persona).toBe('Be terse.')
})

it('omits the field for blank or missing personas', () => {
expect(createTurnRecord({ ...base, persona: ' ' }).persona).toBeUndefined()
expect(createTurnRecord(base).persona).toBeUndefined()
})

it('survives the ThreadSchema persistence round-trip', () => {
const turn = createTurnRecord({ ...base, persona: 'Be terse.' })
const thread = ThreadSchema.parse({
id: 'th1',
workspace: '/tmp',
title: 't',
model: 'm',
mode: 'agent',
status: 'running',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
turns: [turn]
})
expect(thread.turns[0].persona).toBe('Be terse.')
})
})
20 changes: 20 additions & 0 deletions kun/src/contracts/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import {
import { GraphOrchestrationStrategySchema } from './graph.js'
import { GraphPlanningDraftStatusSchema } from './graph-planning.js'

/**
* Upper bound for a turn-scoped persona. Personas are short stance/voice
* guidance, not documents; the cap keeps a mistyped paste from displacing
* conversation context.
*/
export const TURN_PERSONA_MAX_CHARS = 2000

/**
* Mode enum, inlined here (instead of importing `ThreadMode` from
* `threads.js`) to avoid a `threads <-> turns` module init cycle:
Expand Down Expand Up @@ -204,6 +211,12 @@ export const TurnSchema = z.object({
guiDesignMode: z.boolean().optional(),
/** Product surface that owns this turn. Missing legacy values behave as Code. */
agentSurface: z.enum(['code', 'write', 'design']).optional(),
/**
* Turn-scoped persona text chosen by the user in the composer. Rendered as
* a `user`-authority dynamic context block after history, so it never
* touches the immutable prefix or the cached history span.
*/
persona: z.string().max(TURN_PERSONA_MAX_CHARS).optional(),
/** Reserved first-class SVG artifact for structured SVG tools. */
guiDesignArtifact: GuiDesignArtifactContextSchema.optional(),
/**
Expand Down Expand Up @@ -250,6 +263,13 @@ export const StartTurnRequest = z.object({
* mode Kun advertises `create_plan` for the whole conversation.
*/
mode: TurnModeSchema.optional(),
/**
* Optional persona text for this turn only. It guides tone, stance, and
* working style; it cannot grant tools or relax policy. Kun renders it as
* a `user`-authority context block after history so switching personas
* mid-thread leaves the cached prefix and history byte-stable.
*/
persona: z.string().max(TURN_PERSONA_MAX_CHARS).optional(),
/**
* Explicitly selects host-owned Graph orchestration for this turn.
* Missing values preserve the existing direct agent loop.
Expand Down
3 changes: 3 additions & 0 deletions kun/src/domain/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function createTurnRecord(input: {
guiDesignCanvas?: boolean
guiDesignMode?: boolean
agentSurface?: 'code' | 'write' | 'design'
/** Turn-scoped persona text; stored so replay reconstructs the same request. */
persona?: string
guiDesignArtifact?: GuiDesignArtifactContextJson
mode?: ThreadMode
orchestration?: GraphOrchestrationStrategy
Expand Down Expand Up @@ -86,6 +88,7 @@ export function createTurnRecord(input: {
...(input.guiDesignCanvas ? { guiDesignCanvas: true } : {}),
...(input.guiDesignMode ? { guiDesignMode: true } : {}),
...(input.agentSurface ? { agentSurface: input.agentSurface } : {}),
...(input.persona?.trim() ? { persona: input.persona.trim() } : {}),
...(input.guiDesignArtifact ? { guiDesignArtifact: input.guiDesignArtifact } : {}),
...(input.mode ? { mode: input.mode } : {}),
...(input.disableUserInput ? { disableUserInput: true } : {}),
Expand Down
4 changes: 4 additions & 0 deletions kun/src/loop/model-step-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { buildToolPreferenceInstruction } from '../prompt/kun-system-prompt.js'
import {
buildClientSurfaceInstruction,
buildKunTurnContextInstructions,
buildPersonaBlockContent,
type KunTurnContextAuthority,
type KunTurnContextBlock
} from '../prompt/kun-prompt-context.js'
Expand Down Expand Up @@ -635,6 +636,9 @@ export class ModelStepService {
}).map((content) => kunContextBlock('attachment-reference', 'reference', content)),
...memoryInstructions(memories)
.map((content) => kunContextBlock('memory', 'user', content)),
...(turn?.persona?.trim()
? [kunContextBlock('persona', 'user', buildPersonaBlockContent(turn.persona))]
: []),
...(skillResolution.catalogInstruction
? [kunContextBlock('skill-catalog', 'skill', skillResolution.catalogInstruction)]
: []),
Expand Down
34 changes: 34 additions & 0 deletions kun/src/prompt/kun-prompt-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import {
buildKunTurnContextInstructions,
buildPersonaBlockContent
} from './kun-prompt-context.js'

describe('buildPersonaBlockContent', () => {
it('states that the persona governs style but not capability', () => {
const content = buildPersonaBlockContent('Be skeptical.')
expect(content).toContain('Be skeptical.')
expect(content).toContain('does not change which tools exist')
})

it('trims the persona body', () => {
expect(buildPersonaBlockContent(' Be terse. ')).toContain('\nBe terse.')
})
})

describe('persona rendered as a turn context block', () => {
it('renders with user authority and a persona kind', () => {
const instructions = buildKunTurnContextInstructions([
{ kind: 'persona', authority: 'user', content: buildPersonaBlockContent('Be skeptical.') }
])
const rendered = instructions.join('\n')
expect(rendered).toContain('<kun_context_block kind="persona" authority="user">')
expect(rendered).toContain('Be skeptical.')
})

it('emits nothing for blank content, so an unset persona costs no tokens', () => {
expect(buildKunTurnContextInstructions([
{ kind: 'persona', authority: 'user', content: ' ' }
])).toEqual([])
})
})
13 changes: 13 additions & 0 deletions kun/src/prompt/kun-prompt-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export function buildThreadProfileInstruction(profile: string | undefined): stri
].join('\n')
}

/**
* Body of the turn-scoped persona context block. The block markers and the
* turn-context preamble already carry provenance and authority, so this only
* states what the persona governs — style and stance, never capability.
*/
export function buildPersonaBlockContent(persona: string): string {
return [
'Persona the user selected for this message. Apply its stance, tone, and working style.',
'It does not change which tools exist, relax any policy, or outrank the latest explicit user instruction.',
persona.trim()
].join('\n')
}

export function buildClientSurfaceInstruction(surface: TurnClientSurface): string {
const common =
'Use only the tools advertised for this turn. The client surface is presentation context, not extra authorization.'
Expand Down
1 change: 1 addition & 0 deletions kun/src/services/turn-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export class TurnService {
guiDesignCanvas: input.request.guiDesignCanvas,
guiDesignMode: input.request.guiDesignMode,
agentSurface: input.request.agentSurface,
persona: input.request.persona,
guiDesignArtifact: input.request.guiDesignArtifact,
mode: input.request.mode,
orchestration: input.request.orchestration,
Expand Down
28 changes: 28 additions & 0 deletions src/main/ipc/app-ipc-schemas/settings-code-agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { settingsPatchSchema } from './settings'

describe('settings:set codeAgentPresets payload', () => {
it('accepts a persona preset patch from the settings editor', () => {
const parsed = settingsPatchSchema.safeParse({
codeAgentPresets: [
{ id: 'doubter', name: '', icon: 'SearchCheck', persona: '' },
{ id: 'custom-abc', name: 'My persona', icon: 'Brain', persona: 'Be terse.' }
]
})
expect(parsed.success).toBe(true)
})

it('rejects rows with unknown keys (e.g. the legacy emoji field)', () => {
const parsed = settingsPatchSchema.safeParse({
codeAgentPresets: [{ id: 'doubter', emoji: '🧐' }]
})
expect(parsed.success).toBe(false)
})

it('rejects personas over the runtime cap', () => {
const parsed = settingsPatchSchema.safeParse({
codeAgentPresets: [{ id: 'custom-1', persona: 'x'.repeat(2001) }]
})
expect(parsed.success).toBe(false)
})
})
9 changes: 9 additions & 0 deletions src/main/ipc/app-ipc-schemas/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,14 @@ const writeAgentPresetSchema = z.object({
persona: z.string().max(4_000).optional()
}).strict()

const codeAgentPresetSchema = z.object({
id: trimmedString(64),
name: z.string().max(64).optional(),
/** Lucide icon name (PascalCase); unknown names render a fallback icon. */
icon: z.string().max(64).optional(),
persona: z.string().max(2_000).optional()
}).strict()

const writeSettingsPatchSchema = z.object({
defaultWorkspaceRoot: defaultPathSchema,
activeWorkspaceRoot: defaultPathSchema,
Expand Down Expand Up @@ -1402,6 +1410,7 @@ const settingsPatchObjectSchema = z.object({
channel: z.enum(GUI_UPDATE_CHANNELS).optional()
}).strict().optional(),
codePromptPrefix: z.string().max(MAX_CHANNEL_TEXT_LENGTH).optional(),
codeAgentPresets: z.array(codeAgentPresetSchema).max(24).optional(),
disabledSkillIds: z.array(trimmedString(128)).max(512).optional()
}).strict()

Expand Down
2 changes: 2 additions & 0 deletions src/main/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DEFAULT_WRITE_WORKSPACE_ROOT,
DEFAULT_WRITE_WELCOME_FILE_NAME,
defaultClawSettings,
defaultCodeAgentPresets,
defaultKunRuntimeSettings,
defaultModelProviderSettings,
defaultDesignSettings,
Expand Down Expand Up @@ -288,6 +289,7 @@ const defaultSettings = (): AppSettingsV1 => ({
channel: DEFAULT_GUI_UPDATE_CHANNEL
},
codePromptPrefix: '',
codeAgentPresets: defaultCodeAgentPresets(),
disabledSkillIds: [],
write: defaultWriteSettings(),
claw: defaultClawSettings(),
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/agent/kun-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function settings(): AppSettingsV1 {
terminal: defaultTerminalSettings(),
guiUpdate: { channel: 'stable' },
codePromptPrefix: '',
codeAgentPresets: [],
disabledSkillIds: []
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/agent/kun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ export class KunRuntimeProvider implements AgentProvider {
}
guiDesignCanvas?: boolean
guiDesignMode?: boolean
persona?: string
agentSurface?: 'code' | 'write' | 'design'
guiDesignArtifact?: {
kind: 'svg'
Expand Down Expand Up @@ -510,6 +511,9 @@ export class KunRuntimeProvider implements AgentProvider {
if (options?.guiDesignMode) {
body.guiDesignMode = true
}
if (options?.persona?.trim()) {
body.persona = options.persona.trim()
}
if (options?.agentSurface) {
body.agentSurface = options.agentSurface
}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/agent/runtime-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function settings(apiKey: string): AppSettingsV1 {
terminal: defaultTerminalSettings(),
guiUpdate: { channel: 'stable' },
codePromptPrefix: '',
codeAgentPresets: [],
disabledSkillIds: []
}
}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ export interface AgentProvider {
}
guiDesignCanvas?: boolean
guiDesignMode?: boolean
persona?: string
agentSurface?: 'code' | 'write' | 'design'
guiDesignArtifact?: {
kind: 'svg'
Expand Down
Loading
Loading