Skip to content
Open
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
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@v2
with:
Expand All @@ -31,7 +31,7 @@ jobs:
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep

- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock', 'desktop/bun.lock', 'vendor/showai/bun.lock') }}
Expand All @@ -54,13 +54,13 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2

- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock', 'desktop/bun.lock', 'vendor/showai/bun.lock') }}
Expand All @@ -82,13 +82,13 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2

- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock', 'desktop/bun.lock', 'vendor/showai/bun.lock') }}
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to DreamCoder will be documented in this file.

### Added

- Auto is a separate permission mode that applies deterministic rules first and asks a configured decision model only for unresolved confirmations. It can use a dedicated decision provider or the active provider's explicit fast model, fails back to asking on unsupported models and request errors, preserves deny and bypass-immune checks, and records versioned model decisions in the transcript. The desktop `/permissions` command opens permission settings, which document the project rule file and cross-platform shell rule names (#146).
- **Sessions can start other sessions and message each other.** The agent can start a new session with `StartSession`, either fresh with a brief or as a fork of its own conversation, optionally in a new git worktree; you approve every new session, whatever the permission mode. Any session can message any other with `SendMessage`, across projects: a message wakes the receiver, and a progress report from a child to its parent waits for the parent's next turn instead of interrupting it (a parent that is already working sees it right away). Reports waiting for the parent are listed above its message box. The server stamps who sent each message, so a session cannot pass itself off as you or as another session. `ListSessions` lists sessions, and `RenameSession` lets the agent rename its own session (#239).
- **Sessions are nested in the sidebar.** Sessions the agent started, and conversations you forked, appear under the session they came from, with an icon for each kind and a count that collapses or expands them. Messages from another session appear in the conversation as a one-line row with the sender, which expands to the full text (#239).
- **Project board.** Each project gets a persistent board of tasks and sub-tasks that you and your sessions share; every worktree of the project uses the same board. Open it from the project in the sidebar. A task records its owner, reviewer and creator, acceptance criteria, priority, dependencies, progress, and the owner's and reviewer's reports. Sessions read and update the board with four tools (`KanbanList`, `KanbanGet`, `KanbanCreate`, `KanbanUpdate`): they split work into sub-tasks, record progress, block a task with a question, submit their work and review it. The board notifies the sessions involved when they are assigned, when work is submitted, blocked, sent back, reset or cancelled, and when you comment. `StartSession` can start a session as a task's owner or reviewer. The board page has a board view and a hierarchy view, opens a task in the side panel, and lists what waits on you under "Needs me": blocked tasks, deliveries to accept and tasks whose owner session is gone. You approve every time a session creates the board, starts a session on a task, cancels a task, changes acceptance criteria it did not set, or makes itself a reviewer; only a task's creator or you choose its reviewer (#242).
Expand All @@ -18,6 +19,7 @@ All notable changes to DreamCoder will be documented in this file.

- Permission prompts that ask every time, such as starting a session or editing files under `.git/`, no longer offer "Allow for this session", which could not take effect (#242).
- Tools sent to providers through the OpenAI Responses API are no longer in strict mode, so their optional parameters stay optional (#242).
- Permission guardrail settings preserve manually entered decision model IDs, concurrent tool requests show one confirmation at a time, and workspace-rule denials show the blocked command in a copyable plain-text card. Auto uses the active provider's selected model when no separate fast model is configured (#146).
- Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id.
- Reading environment files, credential files, and SSH private keys now requires explicit approval through both the Read tool and read-only Bash commands. Recursive directory reads, Grep tool glob filters, and files read from Git history are not covered yet (#235). Thanks @AIR-Xie.
- The exact shell no-op `:` is recognized as read-only and no longer requires approval (#235). Thanks @AIR-Xie.
Expand Down
33 changes: 29 additions & 4 deletions desktop/src/__tests__/generalSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,28 @@ import type { ProviderPreset } from '../types/providerPreset'
import type { AppMode, ThemeMode, UpdateProxySettings } from '../types/settings'

const MOCK_DELETE_PROVIDER = vi.fn()
const MOCK_GET_SETTINGS = vi.fn()
const MOCK_UPDATE_SETTINGS = vi.fn()
const providerSettingsApiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
}))
const decisionProviderStoreState = vi.hoisted(() => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
error: null,
fetchProviders: vi.fn(async () => {}),
fetchPresets: vi.fn(async () => {}),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
testProvider: vi.fn(),
testConfig: vi.fn(),
discoverModels: vi.fn(),
}))
const MOCK_GET_SETTINGS = providerSettingsApiMock.getSettings
const MOCK_UPDATE_SETTINGS = providerSettingsApiMock.updateSettings
const desktopNotificationsMock = vi.hoisted(() => ({
getDesktopNotificationPermission: vi.fn(),
notifyDesktop: vi.fn(),
Expand Down Expand Up @@ -63,10 +83,14 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: <T,>(selector?: (state: typeof providerStoreState) => T) => selector ? selector(providerStoreState) : providerStoreState,
}))

vi.mock('../stores/decisionProviderStore', () => ({
useDecisionProviderStore: <T,>(selector?: (state: typeof decisionProviderStoreState) => T) => selector ? selector(decisionProviderStoreState) : decisionProviderStoreState,
}))

vi.mock('../api/providers', () => ({
providersApi: {
getSettings: MOCK_GET_SETTINGS,
updateSettings: MOCK_UPDATE_SETTINGS,
getSettings: providerSettingsApiMock.getSettings,
updateSettings: providerSettingsApiMock.updateSettings,
},
}))

Expand Down Expand Up @@ -1269,6 +1293,7 @@ describe('Settings > Providers tab', () => {

fireEvent.click(screen.getByRole('button', { name: /Add Provider|添加服务商/i }))
const dialog = screen.getByRole('dialog')
await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Settings JSON' })).toBeEnabled())
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
fireEvent.change(within(dialog).getByLabelText(/Model ID|模型 ID/i), { target: { value: 'gpt-5.5' } })
fireEvent.click(within(dialog).getAllByRole('button', { name: /Add|添加/i })[0]!)
Expand Down
41 changes: 41 additions & 0 deletions desktop/src/api/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type {
ProviderTestResult,
DiscoveredProviderModel,
DiscoverProviderModelsInput,
SavedDecisionProvider,
DecisionProviderPreset,
CreateDecisionProviderInput,
UpdateDecisionProviderInput,
TestDecisionProviderInput,
DiscoverDecisionProviderModelsInput,
} from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'

Expand All @@ -20,6 +26,9 @@ type AuthStatusResponse = {
source: 'dreamcoder-provider' | 'original-settings' | 'env' | 'none'
activeProvider?: string
}
type DecisionProvidersResponse = { providers: SavedDecisionProvider[] }
type DecisionProviderResponse = { provider: SavedDecisionProvider }
type DecisionPresetsResponse = { presets: DecisionProviderPreset[] }

export const providersApi = {
list() {
Expand Down Expand Up @@ -69,4 +78,36 @@ export const providersApi = {
discoverModels(input: DiscoverProviderModelsInput) {
return api.post<ModelsResponse>('/api/providers/models', input)
},

listDecisions() {
return api.get<DecisionProvidersResponse>('/api/providers/decisions')
},

decisionPresets() {
return api.get<DecisionPresetsResponse>('/api/providers/decisions/presets')
},

createDecision(input: CreateDecisionProviderInput) {
return api.post<DecisionProviderResponse>('/api/providers/decisions', input)
},

updateDecision(id: string, input: UpdateDecisionProviderInput) {
return api.put<DecisionProviderResponse>(`/api/providers/decisions/${id}`, input)
},

deleteDecision(id: string) {
return api.delete<{ ok: true }>(`/api/providers/decisions/${id}`)
},

testDecision(id: string, modelId?: string) {
return api.post<TestResultResponse>(`/api/providers/decisions/${id}/test`, { modelId })
},

testDecisionConfig(input: TestDecisionProviderInput) {
return api.post<TestResultResponse>('/api/providers/decisions/test', input)
},

discoverDecisionModels(input: DiscoverDecisionProviderModelsInput) {
return api.post<ModelsResponse>('/api/providers/decisions/models', input)
},
}
13 changes: 13 additions & 0 deletions desktop/src/components/chat/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1265,4 +1265,17 @@ describe('ChatInput file mentions', () => {
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming')
})
})

it('opens permission settings instead of sending /permissions to the sidecar', () => {
render(<ChatInput />)

fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/permissions', selectionStart: 12 },
})
fireEvent.click(screen.getByRole('button', { name: 'Run' }))

expect(useUIStore.getState().settingsOpen).toBe(true)
expect(useUIStore.getState().settingsTab).toBe('permissions')
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
71 changes: 71 additions & 0 deletions desktop/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,77 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy()
})

it('renders permission guardrail transcript markers with their score', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'guardrail-1',
type: 'permission_guardrail_decision',
decision: 'allow',
toolUseId: 'tool-1',
providerId: 'provider-1',
modelId: 'gpt-6-luna',
source: 'decisionProvider',
score: 0.237,
policyVersion: 1,
timestamp: 1,
}],
}),
},
})

render(<MessageList />)

expect(screen.getByText('Auto-approved')).toBeTruthy()
expect(screen.getByText('gpt-6-luna')).toBeTruthy()
expect(screen.getByText('Decision provider')).toBeTruthy()
expect(screen.getByText('Decision: allow')).toBeTruthy()
expect(screen.getByText('Score: 0.237')).toBeTruthy()
})

it('shows guardrail details on permission requests', () => {
const guardrail = {
providerId: 'provider-1',
modelId: 'gpt-6-luna',
source: 'decisionProvider' as const,
decision: 'ask' as const,
score: 0.812,
policyVersion: 1,
}
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
pendingPermission: {
requestId: 'request-1',
toolName: 'Bash',
toolUseId: 'tool-1',
input: { command: 'rm guarded.txt' },
guardrail,
},
messages: [{
id: 'permission-1',
type: 'permission_request',
requestId: 'request-1',
toolName: 'Bash',
toolUseId: 'tool-1',
input: { command: 'rm guarded.txt' },
guardrail,
timestamp: 1,
}],
}),
},
})

render(<MessageList />)

expect(screen.getByText('Auto permission model')).toBeTruthy()
expect(screen.getByText('gpt-6-luna')).toBeTruthy()
expect(screen.getByText('Decision provider')).toBeTruthy()
expect(screen.getByText('Decision: ask')).toBeTruthy()
expect(screen.getByText('Score: 0.812')).toBeTruthy()
})

it('renders non-agent background progress inline in the transcript', () => {
useChatStore.setState({
sessions: {
Expand Down
43 changes: 42 additions & 1 deletion desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Tooltip as HintTooltip } from '../shared/Tooltip'
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, Settings, ShieldCheck, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore, useSessionField } from '../../stores/chatStore'
Expand Down Expand Up @@ -45,6 +45,7 @@ type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
type CompactSummaryEvent = Extract<UIMessage, { type: 'compact_summary' }>
type PermissionGuardrailDecision = Extract<UIMessage, { type: 'permission_guardrail_decision' }>
type ThinkingMessage = Extract<UIMessage, { type: 'thinking' }>
type ActivityStep = ToolCall | ThinkingMessage

Expand Down Expand Up @@ -852,6 +853,38 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
)
}

function PermissionGuardrailDecisionCard({ message }: { message: PermissionGuardrailDecision }) {
const t = useTranslation()
const score = message.score === null
? t('chat.permissionGuardrail.scoreUnavailable')
: t('chat.permissionGuardrail.score', { score: message.score.toFixed(3) })

return (
<div className="mb-3 flex justify-center px-3">
<div className="flex w-full max-w-2xl items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<ShieldCheck size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<span className="font-medium text-[var(--color-text-primary)]">
{t(message.decision === 'allow'
? 'chat.permissionGuardrail.autoApproved'
: 'chat.permissionGuardrail.confirmationApproved')}
</span>
<span aria-hidden="true">·</span>
<span>{message.modelId}</span>
<span aria-hidden="true">·</span>
<span>{t(message.source === 'decisionProvider'
? 'chat.permissionGuardrail.decisionProvider'
: 'chat.permissionGuardrail.fastModel')}</span>
<span aria-hidden="true">·</span>
<span>{t('chat.permissionGuardrail.decision', { decision: message.decision })}</span>
<span aria-hidden="true">·</span>
<span>{score}</span>
<span aria-hidden="true">·</span>
<span>{t('chat.permissionGuardrail.policyVersion', { version: String(message.policyVersion) })}</span>
</div>
</div>
)
}

type MessageListProps = {
sessionId?: string | null
compact?: boolean
Expand Down Expand Up @@ -1004,6 +1037,8 @@ function getMessageContentWeight(message: UIMessage): number {
return (message.objective?.length ?? 0) + (message.message?.length ?? 0)
case 'memory_event':
return (message.message?.length ?? 0) + message.files.reduce((total, file) => total + file.path.length + (file.summary?.length ?? 0), 0)
case 'permission_guardrail_decision':
return message.modelId.length + message.source.length + (message.reason?.length ?? 0)
case 'background_task':
return getShallowStringWeight(message.task)
case 'task_summary':
Expand Down Expand Up @@ -1064,6 +1099,7 @@ function estimateMessageHeight(message: UIMessage): number {
case 'background_task':
case 'goal_event':
case 'memory_event':
case 'permission_guardrail_decision':
case 'permission_request':
case 'task_summary':
return 110
Expand Down Expand Up @@ -1099,6 +1135,8 @@ function getMessageMetricSignature(message: UIMessage): string {
return `${message.type}:${message.action}:${message.status ?? ''}:${message.objective?.length ?? 0}:${message.message?.length ?? 0}`
case 'memory_event':
return `${message.type}:${message.event}:${message.files.length}:${message.message?.length ?? 0}`
case 'permission_guardrail_decision':
return `${message.type}:${message.toolUseId}:${message.decision}:${message.score ?? ''}`
case 'background_task':
return `${message.type}:${message.task.taskId}:${message.task.status}:${message.task.updatedAt}`
case 'permission_request':
Expand Down Expand Up @@ -2134,6 +2172,7 @@ export const MessageBlock = memo(function MessageBlock({
toolName={message.toolName}
input={message.input}
description={message.description}
guardrail={message.guardrail}
alwaysAsks={message.alwaysAsks}
resolution={message.resolution}
/>
Expand Down Expand Up @@ -2161,6 +2200,8 @@ export const MessageBlock = memo(function MessageBlock({
return <InlineTaskSummary tasks={message.tasks} />
case 'memory_event':
return <MemoryEventCard message={message} />
case 'permission_guardrail_decision':
return <PermissionGuardrailDecisionCard message={message} />
case 'compact_summary':
return <CompactStatusDivider message={message} state={message.phase === 'compacting' ? 'compacting' : 'complete'} />
case 'goal_event':
Expand Down
Loading
Loading