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
29 changes: 23 additions & 6 deletions internal/dashboard/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ func chatReposContext() string {
//
// Injected on the first turn only (like the context hint); later turns inherit it
// via --resume. Kept short and optional so it never forces a question.
const chatQuestionGuidance = "When a choice is genuinely ambiguous and a quick decision from the user would " +
"unblock you, you MAY ask with a fenced `corral-question` block instead of prose — the UI renders the " +
"options as one-click buttons. Use it sparingly (skip it when you can reasonably proceed). Format:\n" +
"```corral-question\nquestion: <the question>\n- <option one>\n- <option two>\n```\n" +
const chatQuestionGuidance = "ASKING THE USER A QUESTION: you do NOT have an interactive question tool here " +
"(any AskUserQuestion-style tool is unavailable and its call is silently discarded — the user never sees it). " +
"The ONLY way to ask the user something is a fenced `corral-question` block, which the UI renders as one-click " +
"buttons. When a choice is genuinely ambiguous and a quick decision would unblock you, ask with this block " +
"(never with a tool, never buried in prose). Use it sparingly — skip it when you can reasonably proceed. " +
"Format:\n```corral-question\nquestion: <the question>\n- <option one>\n- <option two>\n```\n" +
"One question per block, 2–4 short options. After the block, stop and wait for the answer."

// chatConductorGuidance is prepended to the GLOBAL chat's first turn so the
Expand Down Expand Up @@ -510,7 +512,7 @@ func isExecutable(path string) bool {
// --allowedTools entirely when no tools are granted: a bare "--allowedTools"
// with no following value is a malformed flag that makes `claude` fail — the
// bug that broke the PR-review chat (which grants no tools).
func buildClaudeArgs(prompt, systemPrompt string, tools []string, sessionID string) []string {
func buildClaudeArgs(prompt, systemPrompt string, tools, disallowed []string, sessionID string) []string {
args := []string{
"-p", prompt,
"--output-format", "stream-json", "--verbose",
Expand Down Expand Up @@ -541,6 +543,16 @@ func buildClaudeArgs(prompt, systemPrompt string, tools []string, sessionID stri
args = append(args, "--allowedTools")
args = append(args, tools...)
}
// Deny specific tools. The conductor passes AskUserQuestion here: that native
// tool is auto-DISMISSED in headless `claude -p` mode (no interactive channel
// to answer it over the stream), so a question asked with it silently vanishes
// — the user sees nothing and the model gets an empty "dismissed" result. It's
// intermittent because the model sometimes uses the answerable `corral-question`
// fenced convention instead; denying the native tool forces it onto that path.
if len(disallowed) > 0 {
args = append(args, "--disallowedTools")
args = append(args, disallowed...)
}
if sessionID != "" {
args = append(args, "--resume", sessionID)
}
Expand Down Expand Up @@ -569,10 +581,15 @@ func (d *dashboardServer) runChatTurn(ctx context.Context, claudeBin, workspace
// The global chat (workspace=="") is the conductor: give it its operating rules
// as a system prompt on EVERY turn, so a resumed/multi-turn session keeps them.
sysPrompt := ""
var disallowed []string
if workspace == "" {
sysPrompt = conductorSystemPrompt()
// The conductor asks questions via the `corral-question` fenced convention
// (answerable over the stream). Deny the native AskUserQuestion tool, which
// is auto-dismissed in headless mode — so a question can't silently vanish.
disallowed = []string{"AskUserQuestion"}
}
args := buildClaudeArgs(prompt, sysPrompt, tools, sessionID)
args := buildClaudeArgs(prompt, sysPrompt, tools, disallowed, sessionID)

cmd := exec.CommandContext(ctx, claudeBin, args...)
// Cross-origin linkage: stamp the conversation driving THIS turn into the
Expand Down
23 changes: 19 additions & 4 deletions internal/dashboard/chat_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ func argsContain(args []string, flag string) bool {

func TestBuildClaudeArgs(t *testing.T) {
// No tools (PR-review chat), no system prompt.
noTools := buildClaudeArgs("hi", "", nil, "")
noTools := buildClaudeArgs("hi", "", nil, nil, "")
if argsContain(noTools, "--allowedTools") {
t.Errorf("empty tools should omit --allowedTools, got: %v", noTools)
}
if argsContain(noTools, "--disallowedTools") {
t.Errorf("no disallowed should omit --disallowedTools, got: %v", noTools)
}
if argsContain(noTools, "--append-system-prompt") {
t.Errorf("empty system prompt should omit --append-system-prompt, got: %v", noTools)
}
Expand All @@ -27,8 +30,20 @@ func TestBuildClaudeArgs(t *testing.T) {
t.Errorf("missing base args: %v", noTools)
}

// Disallowed tools: --disallowedTools immediately followed by the names.
withDenied := buildClaudeArgs("hi", "", nil, []string{"AskUserQuestion"}, "")
d := -1
for k, a := range withDenied {
if a == "--disallowedTools" {
d = k
}
}
if d < 0 || d+1 >= len(withDenied) || withDenied[d+1] != "AskUserQuestion" {
t.Errorf("expected --disallowedTools followed by AskUserQuestion, got: %v", withDenied)
}

// With tools (project chat): --allowedTools immediately followed by tools.
withTools := buildClaudeArgs("hi", "", []string{"Read", "Grep"}, "")
withTools := buildClaudeArgs("hi", "", []string{"Read", "Grep"}, nil, "")
i := -1
for k, a := range withTools {
if a == "--allowedTools" {
Expand All @@ -43,7 +58,7 @@ func TestBuildClaudeArgs(t *testing.T) {
}

// A system prompt (the conductor) appends --append-system-prompt + its value.
withSys := buildClaudeArgs("hi", "RULES HERE", nil, "")
withSys := buildClaudeArgs("hi", "RULES HERE", nil, nil, "")
j := -1
for k, a := range withSys {
if a == "--append-system-prompt" {
Expand All @@ -55,7 +70,7 @@ func TestBuildClaudeArgs(t *testing.T) {
}

// Session id appends --resume.
resumed := buildClaudeArgs("hi", "", nil, "sess123")
resumed := buildClaudeArgs("hi", "", nil, nil, "sess123")
if !argsContain(resumed, "--resume") || !argsContain(resumed, "sess123") {
t.Errorf("expected --resume sess123, got: %v", resumed)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/dashboard/chat_skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func TestChatLoadsCorralApiSkill(t *testing.T) {
// Absent bundle → no --plugin-dir, no panic.
t.Setenv("CORRAL_HOME", t.TempDir())
if got := strings.Join(buildClaudeArgs("hi", "", nil, ""), " "); strings.Contains(got, "--plugin-dir") {
if got := strings.Join(buildClaudeArgs("hi", "", nil, nil, ""), " "); strings.Contains(got, "--plugin-dir") {
t.Errorf("expected no --plugin-dir with an empty bundle, got: %s", got)
}

Expand All @@ -37,7 +37,7 @@ func TestChatLoadsCorralApiSkill(t *testing.T) {
t.Fatal(err)
}

args := buildClaudeArgs("hi", "", nil, "")
args := buildClaudeArgs("hi", "", nil, nil, "")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--plugin-dir") || !strings.Contains(joined, skillDir) {
t.Errorf("expected --plugin-dir %s in args, got: %s", skillDir, joined)
Expand Down
17 changes: 14 additions & 3 deletions internal/dashboard/conv_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,20 @@ func TestCaptureSendRecordsAndPassesThrough(t *testing.T) {
}
finalize("done")

// Every frame reached the browser, in order.
if len(delivered) != len(frames) {
t.Fatalf("delivered %d frames, want %d", len(delivered), len(frames))
// Every frame reached the browser, PLUS the one-shot conv_meta frame the
// capturer injects when it first creates the conversation row (it carries the
// conv id/uuid to the UI). So delivered = the 6 sent frames + 1 conv_meta.
var metaCount int
for _, m := range delivered {
if m.Type == "conv_meta" {
metaCount++
}
}
if metaCount != 1 {
t.Fatalf("expected exactly 1 injected conv_meta frame, got %d", metaCount)
}
if len(delivered) != len(frames)+1 {
t.Fatalf("delivered %d frames, want %d (6 sent + 1 conv_meta)", len(delivered), len(frames)+1)
}

// The conversation + messages persisted. Prompt(user) + text + tool_use +
Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/worker_resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
// is a real safety boundary — it must never be bypassed. Fails loudly if anyone
// reintroduces a bypass into the argv.
func TestHostClaudeStaysGated(t *testing.T) {
args := strings.Join(buildClaudeArgs("hi", "", []string{"Bash"}, ""), " ")
args := strings.Join(buildClaudeArgs("hi", "", []string{"Bash"}, nil, ""), " ")
if !strings.Contains(args, "--permission-mode default") {
t.Fatalf("host claude must use --permission-mode default, got: %s", args)
}
Expand Down
Loading