Skip to content

Commit f66954b

Browse files
committed
Improve OpenClaw DX context and workspace behavior
1 parent 8b987aa commit f66954b

File tree

38 files changed

+7771
-728
lines changed

38 files changed

+7771
-728
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ Then run `amux` to open the dashboard.
5959

6060
Each workspace tracks a repo checkout and its metadata. For local workflows, workspaces are typically backed by git worktrees on their own branches so agents work in isolation and you can merge changes back when done.
6161

62+
Workspace scope terminology:
63+
- **Project workspace**: created directly for a project (`amux workspace create <name> --project <repo>`). By default, it starts from the project's default branch.
64+
- **Nested workspace**: created from an existing workspace context (OpenClaw DX: `workspace create --scope nested --from-workspace <id>`). It remains isolated, and also starts from the project's default branch.
65+
6266
## Architecture quick tour
6367

6468
Start with `internal/app/ARCHITECTURE.md` for lifecycle, PTY flow, tmux tagging, and persistence invariants. Message boundaries and command discipline are documented in `internal/app/MESSAGE_FLOW.md`.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cli
2+
3+
import (
4+
"os/exec"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestOpenClawDogfoodScript_MissingFlagValueFailsClearly(t *testing.T) {
11+
requireBinary(t, "bash")
12+
13+
scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dogfood.sh")
14+
cmd := exec.Command(scriptPath, "--repo")
15+
out, err := cmd.CombinedOutput()
16+
if err == nil {
17+
t.Fatalf("expected non-zero exit for missing flag value")
18+
}
19+
text := string(out)
20+
if !strings.Contains(text, "missing value for --repo") {
21+
t.Fatalf("output = %q, want missing flag guidance", text)
22+
}
23+
}

internal/cli/openclaw_dx_script_assistants_git_test.go

Lines changed: 112 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ echo ready
6060
if got, _ := payload["command"].(string); got != "assistants" {
6161
t.Fatalf("command = %q, want %q", got, "assistants")
6262
}
63-
if got, _ := payload["status"].(string); got != "attention" {
64-
t.Fatalf("status = %q, want %q", got, "attention")
63+
if got, _ := payload["status"].(string); got == "needs_input" {
64+
t.Fatalf("status = %q, want non-blocking status when at least one assistant is ready", got)
6565
}
6666
data, ok := payload["data"].(map[string]any)
6767
if !ok {
@@ -157,6 +157,10 @@ if [[ "$assistant" == "aa-pass" ]]; then
157157
printf '%s' '{"ok":true,"status":"idle","overall_status":"completed","summary":"READY: codex objective identified."}'
158158
exit 0
159159
fi
160+
if [[ "$assistant" == "codex" ]]; then
161+
printf '%s' '{"ok":true,"status":"idle","overall_status":"completed","summary":"READY: codex objective identified."}'
162+
exit 0
163+
fi
160164
printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","summary":"Needs local permission confirmation."}'
161165
`)
162166

@@ -175,8 +179,8 @@ printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","s
175179
if got, _ := payload["command"].(string); got != "assistants" {
176180
t.Fatalf("command = %q, want %q", got, "assistants")
177181
}
178-
if got, _ := payload["status"].(string); got != "needs_input" {
179-
t.Fatalf("status = %q, want %q", got, "needs_input")
182+
if got, _ := payload["status"].(string); got == "needs_input" {
183+
t.Fatalf("status = %q, want non-blocking status when at least one probe passed", got)
180184
}
181185
data, ok := payload["data"].(map[string]any)
182186
if !ok {
@@ -191,6 +195,9 @@ printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","s
191195
if got, _ := data["probe_needs_input"].(float64); got != 1 {
192196
t.Fatalf("probe_needs_input = %v, want 1", got)
193197
}
198+
if got, _ := data["workspace_label"].(string); got != "ws-1 (demo) [project workspace]" {
199+
t.Fatalf("workspace_label = %q, want %q", got, "ws-1 (demo) [project workspace]")
200+
}
194201
probes, ok := data["probes"].([]any)
195202
if !ok || len(probes) != 2 {
196203
t.Fatalf("probes = %#v, want len=2", data["probes"])
@@ -202,51 +209,58 @@ printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","s
202209
if !ok {
203210
continue
204211
}
205-
assistant, _ := probe["assistant"].(string)
206212
result, _ := probe["result"].(string)
207-
if assistant == "aa-pass" && result == "passed" {
213+
if result == "passed" {
208214
sawPassed = true
209215
}
210-
if assistant == "ab-needs" && result == "needs_input" {
216+
if result == "needs_input" {
211217
sawNeedsInput = true
212218
}
213219
}
214220
if !sawPassed || !sawNeedsInput {
215221
t.Fatalf("probe results missing expected entries: %#v", probes)
216222
}
223+
224+
channel, ok := payload["channel"].(map[string]any)
225+
if !ok {
226+
t.Fatalf("channel missing or wrong type: %T", payload["channel"])
227+
}
228+
message, _ := channel["message"].(string)
229+
if !strings.Contains(message, "Workspace: ws-1 (demo) [project workspace]") {
230+
t.Fatalf("channel.message = %q, want workspace label", message)
231+
}
232+
233+
quickActions, ok := payload["quick_actions"].([]any)
234+
if !ok || len(quickActions) == 0 {
235+
t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"])
236+
}
237+
var sawReview bool
238+
for _, raw := range quickActions {
239+
action, ok := raw.(map[string]any)
240+
if !ok {
241+
continue
242+
}
243+
id, _ := action["id"].(string)
244+
cmd, _ := action["command"].(string)
245+
if id == "review" && strings.Contains(cmd, "review --workspace ws-1 --assistant codex") {
246+
sawReview = true
247+
}
248+
}
249+
if !sawReview {
250+
t.Fatalf("expected workspace-specific review quick action in %#v", quickActions)
251+
}
217252
}
218253

219-
func TestOpenClawDXGitShip_CommitsWorkspaceChanges(t *testing.T) {
254+
func TestOpenClawDXAssistants_ProbePrioritizesCodexUnderLimit(t *testing.T) {
220255
requireBinary(t, "jq")
221256
requireBinary(t, "bash")
222-
requireBinary(t, "git")
223257

224258
scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh")
225259
fakeBinDir := t.TempDir()
226260
fakeAmuxPath := filepath.Join(fakeBinDir, "amux")
227-
228-
repoDir := t.TempDir()
229-
if out, err := exec.Command("git", "-C", repoDir, "init", "-b", "main").CombinedOutput(); err != nil {
230-
t.Fatalf("git init: %v\n%s", err, string(out))
231-
}
232-
if out, err := exec.Command("git", "-C", repoDir, "config", "user.email", "dx@example.com").CombinedOutput(); err != nil {
233-
t.Fatalf("git config email: %v\n%s", err, string(out))
234-
}
235-
if out, err := exec.Command("git", "-C", repoDir, "config", "user.name", "DX Bot").CombinedOutput(); err != nil {
236-
t.Fatalf("git config name: %v\n%s", err, string(out))
237-
}
238-
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil {
239-
t.Fatalf("write README: %v", err)
240-
}
241-
if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil {
242-
t.Fatalf("git add: %v\n%s", err, string(out))
243-
}
244-
if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "initial").CombinedOutput(); err != nil {
245-
t.Fatalf("git commit initial: %v\n%s", err, string(out))
246-
}
247-
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
248-
t.Fatalf("modify README: %v", err)
249-
}
261+
fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh")
262+
codexPath := filepath.Join(fakeBinDir, "codex")
263+
ampPath := filepath.Join(fakeBinDir, "amp")
250264

251265
writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash
252266
set -euo pipefail
@@ -255,53 +269,67 @@ if [[ "${1:-}" == "--json" ]]; then
255269
fi
256270
case "${1:-} ${2:-}" in
257271
"workspace list")
258-
printf '%s' "${FAKE_WORKSPACE_LIST_JSON:?missing FAKE_WORKSPACE_LIST_JSON}"
272+
printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","assistant":"codex"}],"error":null}'
259273
;;
260274
*)
261-
printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*"
275+
printf '%s' '{"ok":true,"data":{},"error":null}'
262276
;;
263277
esac
278+
`)
279+
writeExecutable(t, codexPath, "#!/usr/bin/env bash\nset -euo pipefail\necho codex\n")
280+
writeExecutable(t, ampPath, "#!/usr/bin/env bash\nset -euo pipefail\necho amp\n")
281+
282+
writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash
283+
set -euo pipefail
284+
assistant=""
285+
for ((i=1; i<=$#; i++)); do
286+
if [[ "${!i}" == "--assistant" ]]; then
287+
next=$((i+1))
288+
assistant="${!next}"
289+
fi
290+
done
291+
if [[ "$assistant" == "codex" ]]; then
292+
printf '%s' '{"ok":true,"status":"idle","overall_status":"completed","summary":"READY: codex can run non-interactive."}'
293+
exit 0
294+
fi
295+
printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","summary":"Needs local permission confirmation."}'
264296
`)
265297

266-
workspaceListJSON := `{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"` + repoDir + `","root":"` + repoDir + `"}],"error":null}`
267298
env := os.Environ()
268299
env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH"))
269-
env = withEnv(env, "FAKE_WORKSPACE_LIST_JSON", workspaceListJSON)
300+
env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath)
270301

271302
payload := runScriptJSON(t, scriptPath, env,
272-
"git", "ship",
303+
"assistants",
273304
"--workspace", "ws-1",
274-
"--message", "feat: update readme",
305+
"--probe",
306+
"--limit", "1",
275307
)
276308

277-
if got, _ := payload["command"].(string); got != "git.ship" {
278-
t.Fatalf("command = %q, want %q", got, "git.ship")
279-
}
280-
if got, _ := payload["status"].(string); got != "ok" {
281-
t.Fatalf("status = %q, want %q", got, "ok")
282-
}
283309
data, ok := payload["data"].(map[string]any)
284310
if !ok {
285311
t.Fatalf("data missing or wrong type: %T", payload["data"])
286312
}
287-
commitHash, _ := data["commit_hash"].(string)
288-
if strings.TrimSpace(commitHash) == "" {
289-
t.Fatalf("commit_hash is empty: %#v", data)
313+
if got, _ := data["probe_count"].(float64); got != 1 {
314+
t.Fatalf("probe_count = %v, want 1", got)
290315
}
291-
if pushed, _ := data["pushed"].(bool); pushed {
292-
t.Fatalf("pushed = true, expected false in local-only test")
316+
if got, _ := data["probe_passed"].(float64); got != 1 {
317+
t.Fatalf("probe_passed = %v, want 1", got)
293318
}
294-
295-
logOut, err := exec.Command("git", "-C", repoDir, "log", "-1", "--pretty=%s").CombinedOutput()
296-
if err != nil {
297-
t.Fatalf("git log: %v\n%s", err, string(logOut))
319+
probes, ok := data["probes"].([]any)
320+
if !ok || len(probes) != 1 {
321+
t.Fatalf("probes = %#v, want len=1", data["probes"])
298322
}
299-
if got := strings.TrimSpace(string(logOut)); got != "feat: update readme" {
300-
t.Fatalf("last commit message = %q, want %q", got, "feat: update readme")
323+
firstProbe, ok := probes[0].(map[string]any)
324+
if !ok {
325+
t.Fatalf("probe[0] wrong type: %T", probes[0])
326+
}
327+
if got, _ := firstProbe["assistant"].(string); got != "codex" {
328+
t.Fatalf("first probed assistant = %q, want codex", got)
301329
}
302330
}
303331

304-
func TestOpenClawDXGitShip_NoChangesButAheadSuggestsPush(t *testing.T) {
332+
func TestOpenClawDXGitShip_CommitsWorkspaceChanges(t *testing.T) {
305333
requireBinary(t, "jq")
306334
requireBinary(t, "bash")
307335
requireBinary(t, "git")
@@ -320,15 +348,6 @@ func TestOpenClawDXGitShip_NoChangesButAheadSuggestsPush(t *testing.T) {
320348
if out, err := exec.Command("git", "-C", repoDir, "config", "user.name", "DX Bot").CombinedOutput(); err != nil {
321349
t.Fatalf("git config name: %v\n%s", err, string(out))
322350
}
323-
324-
remoteDir := filepath.Join(t.TempDir(), "remote.git")
325-
if out, err := exec.Command("git", "init", "--bare", remoteDir).CombinedOutput(); err != nil {
326-
t.Fatalf("git init bare: %v\n%s", err, string(out))
327-
}
328-
if out, err := exec.Command("git", "-C", repoDir, "remote", "add", "origin", remoteDir).CombinedOutput(); err != nil {
329-
t.Fatalf("git remote add: %v\n%s", err, string(out))
330-
}
331-
332351
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil {
333352
t.Fatalf("write README: %v", err)
334353
}
@@ -338,19 +357,9 @@ func TestOpenClawDXGitShip_NoChangesButAheadSuggestsPush(t *testing.T) {
338357
if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "initial").CombinedOutput(); err != nil {
339358
t.Fatalf("git commit initial: %v\n%s", err, string(out))
340359
}
341-
if out, err := exec.Command("git", "-C", repoDir, "push", "-u", "origin", "HEAD").CombinedOutput(); err != nil {
342-
t.Fatalf("git push initial: %v\n%s", err, string(out))
343-
}
344-
345360
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
346361
t.Fatalf("modify README: %v", err)
347362
}
348-
if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil {
349-
t.Fatalf("git add second: %v\n%s", err, string(out))
350-
}
351-
if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "second").CombinedOutput(); err != nil {
352-
t.Fatalf("git commit second: %v\n%s", err, string(out))
353-
}
354363

355364
writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash
356365
set -euo pipefail
@@ -375,36 +384,40 @@ esac
375384
payload := runScriptJSON(t, scriptPath, env,
376385
"git", "ship",
377386
"--workspace", "ws-1",
387+
"--message", "feat: update readme",
378388
)
379389

380-
if got, _ := payload["status"].(string); got != "attention" {
381-
t.Fatalf("status = %q, want %q", got, "attention")
390+
if got, _ := payload["command"].(string); got != "git.ship" {
391+
t.Fatalf("command = %q, want %q", got, "git.ship")
382392
}
383-
summary, _ := payload["summary"].(string)
384-
if !strings.Contains(summary, "ready to push") {
385-
t.Fatalf("summary = %q, want push-ready guidance", summary)
393+
if got, _ := payload["status"].(string); got != "ok" {
394+
t.Fatalf("status = %q, want %q", got, "ok")
386395
}
387-
suggested, _ := payload["suggested_command"].(string)
388-
if !strings.Contains(suggested, "git ship --workspace ws-1 --push") {
389-
t.Fatalf("suggested_command = %q, want push command", suggested)
396+
channel, ok := payload["channel"].(map[string]any)
397+
if !ok {
398+
t.Fatalf("channel missing or wrong type: %T", payload["channel"])
390399
}
391-
quickActions, ok := payload["quick_actions"].([]any)
392-
if !ok || len(quickActions) == 0 {
393-
t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"])
400+
message, _ := channel["message"].(string)
401+
if !strings.Contains(message, "Workspace: ws-1 (demo) [project workspace]") {
402+
t.Fatalf("channel.message = %q, want workspace context label", message)
394403
}
395-
var sawPush bool
396-
for _, raw := range quickActions {
397-
action, ok := raw.(map[string]any)
398-
if !ok {
399-
continue
400-
}
401-
id, _ := action["id"].(string)
402-
if id == "push" {
403-
sawPush = true
404-
break
405-
}
404+
data, ok := payload["data"].(map[string]any)
405+
if !ok {
406+
t.Fatalf("data missing or wrong type: %T", payload["data"])
406407
}
407-
if !sawPush {
408-
t.Fatalf("expected push quick action in %#v", quickActions)
408+
commitHash, _ := data["commit_hash"].(string)
409+
if strings.TrimSpace(commitHash) == "" {
410+
t.Fatalf("commit_hash is empty: %#v", data)
411+
}
412+
if pushed, _ := data["pushed"].(bool); pushed {
413+
t.Fatalf("pushed = true, expected false in local-only test")
414+
}
415+
416+
logOut, err := exec.Command("git", "-C", repoDir, "log", "-1", "--pretty=%s").CombinedOutput()
417+
if err != nil {
418+
t.Fatalf("git log: %v\n%s", err, string(logOut))
419+
}
420+
if got := strings.TrimSpace(string(logOut)); got != "feat: update readme" {
421+
t.Fatalf("last commit message = %q, want %q", got, "feat: update readme")
409422
}
410423
}

0 commit comments

Comments
 (0)