1168 lines
42 KiB
Go
1168 lines
42 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
acp "github.com/coder/acp-go-sdk"
|
|
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
)
|
|
|
|
// Predefined e2e test scenarios with fixed timing for deterministic test assertions.
|
|
|
|
// scenarioRegistry maps scenario names to their handler functions.
|
|
var scenarioRegistry = map[string]func(e *emitter){
|
|
"simple-message": scenarioSimpleMessage,
|
|
"read-and-edit": scenarioReadAndEdit,
|
|
"permission-flow": scenarioPermissionFlow,
|
|
toolKeyError: scenarioError,
|
|
"subagent": scenarioSubagent,
|
|
"all-tools": scenarioAllTools,
|
|
"multi-turn": scenarioMultiTurn,
|
|
"diff-expansion-setup": scenarioDiffExpansionSetup,
|
|
"diff-update-setup": scenarioDiffUpdateSetup,
|
|
"diff-update-modify": scenarioDiffUpdateModify,
|
|
"diff-update-streaming": scenarioDiffUpdateStreaming,
|
|
"multi-file-setup": scenarioMultiFileSetup,
|
|
"multi-file-modify": scenarioMultiFileModify,
|
|
"untracked-file-setup": scenarioUntrackedFileSetup,
|
|
"untracked-file-modify": scenarioUntrackedFileModify,
|
|
"clarification": scenarioClarification,
|
|
"clarification-multi": scenarioClarificationMulti,
|
|
"clarification-timeout": scenarioClarificationTimeout,
|
|
"multi-permission": scenarioMultiPermission,
|
|
"kandev-mcp-permission": scenarioKandevMCPPermission,
|
|
"review-cumulative-setup": scenarioReviewCumulativeSetup,
|
|
"walkthrough-setup": scenarioWalkthroughSetup,
|
|
"walkthrough-basic": scenarioWalkthroughBasic,
|
|
"walkthrough-reemit": scenarioWalkthroughReemit,
|
|
"symlink-file-setup": scenarioSymlinkFileSetup,
|
|
"markdown-table": scenarioMarkdownTable,
|
|
"empty-turn": scenarioEmptyTurn,
|
|
"push-current-branch": scenarioPushCurrentBranch,
|
|
"steer-fold-setup": scenarioSteerFoldSetup,
|
|
"steer-defer-setup": scenarioSteerDeferSetup,
|
|
}
|
|
|
|
// steerSetupHoldMillis is how long steer-fold-setup and steer-defer-setup
|
|
// keep their prompt open after any setup output, so the session reads
|
|
// "generating" (steer-eligible) for long enough that an e2e test can reliably
|
|
// deliver a mid-turn steer against it. Cancellation (turn end, session
|
|
// cleanup) interrupts the hold immediately via waitForDelay's ctx.Done case.
|
|
const steerSetupHoldMillis = 30_000
|
|
|
|
// scenarioSteerFoldSetup holds the foreground turn open without ever emitting
|
|
// text of its own, so a mid-turn steer delivered while it runs can only be
|
|
// answered by the steer's own successor turn. This reproduces the "folded"
|
|
// outcome from the mid-turn steering spec's outcome taxonomy
|
|
// (docs/specs/platform/mid-turn-steering.md): the predecessor settles without
|
|
// having produced an answer, and the operator sees a single, combined reply.
|
|
func scenarioSteerFoldSetup(e *emitter) {
|
|
waitForDelay(e.ctx, steerSetupHoldMillis)
|
|
}
|
|
|
|
// scenarioSteerDeferSetup answers its own prompt immediately, then continues
|
|
// to hold the turn open silently. A mid-turn steer delivered during the hold
|
|
// still reaches the agent as a concurrent prompt, but runs as a genuinely
|
|
// separate turn with its own answer -- the "deferred" outcome from the
|
|
// outcome taxonomy. The transcript therefore shows two independent answers in
|
|
// submission order, matching an installation whose agent CLI never folds.
|
|
func scenarioSteerDeferSetup(e *emitter) {
|
|
e.text("Predecessor turn's own answer, delivered before any steer arrives.")
|
|
// A tool-call boundary flushes the buffered text above into its own
|
|
// message row (cmd/mock-agent/AGENTS.md: "flushMessageBuffer only fires
|
|
// on a tool-call/turn boundary"). Without it the answer stays buffered
|
|
// server-side for as long as this turn holds open below, so an operator
|
|
// (or an e2e assertion) would see nothing until the turn eventually ends.
|
|
flushID := nextToolID()
|
|
e.startTool(flushID, "Acknowledge predecessor answer", acp.ToolKindOther, map[string]any{})
|
|
e.completeTool(flushID, map[string]any{toolKeyResult: "ok"})
|
|
waitForDelay(e.ctx, steerSetupHoldMillis)
|
|
}
|
|
|
|
// scenarioEmptyTurn emits no content and no tool calls, so the turn ends
|
|
// cleanly with no agent output. Reproduces the case where an agent treats a
|
|
// prompt (e.g. an unsupported slash command) as a no-op and returns an empty
|
|
// end_turn. Drives the frontend "empty turn" notice.
|
|
func scenarioEmptyTurn(e *emitter) {
|
|
_ = e
|
|
// Stay "running" for a few seconds rather than returning instantly. The
|
|
// empty-turn notice is driven by the live turn.completed event, so the turn
|
|
// must outlast the client's initial WS subscribe (especially on mobile,
|
|
// where a fast auto-start turn can finish before the chat subscribes).
|
|
fixedDelay(3000)
|
|
}
|
|
|
|
// emitPredefinedScenario dispatches to a named e2e scenario.
|
|
func emitPredefinedScenario(e *emitter, name string) {
|
|
if fn, ok := scenarioRegistry[name]; ok {
|
|
fn(e)
|
|
} else {
|
|
names := make([]string, 0, len(scenarioRegistry))
|
|
for k := range scenarioRegistry {
|
|
names = append(names, k)
|
|
}
|
|
e.text("Unknown e2e scenario: " + name + ". Available: " + strings.Join(names, ", "))
|
|
}
|
|
}
|
|
|
|
// scenarioSimpleMessage: text only with fixed 100ms delays.
|
|
func scenarioSimpleMessage(e *emitter) {
|
|
fixedDelay(100)
|
|
e.thought("Processing the request...")
|
|
|
|
fixedDelay(100)
|
|
e.text("This is a simple mock response for e2e testing.")
|
|
}
|
|
|
|
// scenarioReadAndEdit: read -> edit -> text with fixed delays, using real files.
|
|
func scenarioReadAndEdit(e *emitter) {
|
|
f := randomFile()
|
|
snippet := readFileSnippet(f.absPath, 20)
|
|
oldStr, newStr := pickEditableFragment(f.absPath)
|
|
fixedDelay(50)
|
|
|
|
// Read file
|
|
readID := nextToolID()
|
|
e.startTool(readID, "Read "+f.relPath, acp.ToolKindRead,
|
|
map[string]any{"file_path": f.absPath},
|
|
acp.ToolCallLocation{Path: f.absPath})
|
|
fixedDelay(50)
|
|
e.completeTool(readID, map[string]any{"content": snippet})
|
|
|
|
fixedDelay(50)
|
|
|
|
// Edit file (with permission)
|
|
editID := nextToolID()
|
|
editInput := map[string]any{
|
|
"file_path": f.absPath,
|
|
"old_string": oldStr,
|
|
"new_string": newStr,
|
|
}
|
|
e.startTool(editID, "Edit "+f.relPath, acp.ToolKindEdit, editInput,
|
|
acp.ToolCallLocation{Path: f.absPath})
|
|
|
|
allowed := e.requestPermission(editID, "Edit "+f.relPath, acp.ToolKindEdit, editInput)
|
|
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(editID, map[string]any{toolKeyResult: "File edited successfully: " + f.absPath})
|
|
} else {
|
|
e.completeTool(editID, map[string]any{toolKeyResult: "Edit was denied"})
|
|
e.text("Edit was denied.")
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text("Read and edit scenario complete.")
|
|
}
|
|
|
|
// scenarioMultiPermission: three sequential bash tools, each requiring permission.
|
|
// Reproduces the "approvals reappear at turn complete" bug — user approves all
|
|
// three, then when the turn ends the approval prompts must NOT come back.
|
|
func scenarioMultiPermission(e *emitter) {
|
|
fixedDelay(50)
|
|
e.text("Running three commands that need approval.")
|
|
|
|
for i, label := range []string{"first", "second", "third"} {
|
|
fixedDelay(50)
|
|
id := nextToolID()
|
|
input := map[string]any{
|
|
"command": fmt.Sprintf("echo %s", label),
|
|
"description": fmt.Sprintf("Step %d: %s", i+1, label),
|
|
}
|
|
e.startTool(id, fmt.Sprintf("Run %s command", label), acp.ToolKindExecute, input)
|
|
allowed := e.requestPermission(id, fmt.Sprintf("Run %s command", label), acp.ToolKindExecute, input)
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(id, map[string]any{"output": label})
|
|
} else {
|
|
e.completeTool(id, map[string]any{"output": "denied"})
|
|
}
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text("Multi-permission scenario complete.")
|
|
}
|
|
|
|
// scenarioKandevMCPPermission emits a tool_call with a Kandev MCP tool title and
|
|
// blocks on a permission request, exercising the kandev renderer's approval UI.
|
|
func scenarioKandevMCPPermission(e *emitter) {
|
|
fixedDelay(50)
|
|
e.text("Calling a Kandev MCP tool that needs approval.")
|
|
|
|
fixedDelay(50)
|
|
id := nextToolID()
|
|
toolName := "mcp__kandev__list_workspaces_kandev"
|
|
input := map[string]any{}
|
|
e.startTool(id, toolName, acp.ToolKindOther, input)
|
|
allowed := e.requestPermission(id, toolName, acp.ToolKindOther, input)
|
|
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(id, map[string]any{
|
|
"workspaces": []map[string]any{{"id": "w1", "name": "Main"}},
|
|
"total": 1,
|
|
})
|
|
} else {
|
|
e.completeTool(id, map[string]any{toolKeyError: "denied"})
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text("Kandev MCP permission scenario complete.")
|
|
}
|
|
|
|
// scenarioPermissionFlow: tool requiring permission with fixed delays.
|
|
func scenarioPermissionFlow(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
bashID := nextToolID()
|
|
bashInput := map[string]any{
|
|
"command": "echo 'testing permissions'",
|
|
"description": "Test permission flow",
|
|
}
|
|
|
|
e.startTool(bashID, "Test permissions", acp.ToolKindExecute, bashInput)
|
|
|
|
allowed := e.requestPermission(bashID, "Test permissions", acp.ToolKindExecute, bashInput)
|
|
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(bashID, map[string]any{"output": "testing permissions"})
|
|
e.text("Permission was granted and command executed.")
|
|
} else {
|
|
e.completeTool(bashID, map[string]any{"output": "Permission denied"})
|
|
e.text("Permission was denied.")
|
|
}
|
|
}
|
|
|
|
// scenarioError: error result with fixed delays.
|
|
func scenarioError(e *emitter) {
|
|
fixedDelay(100)
|
|
e.text("About to encounter an error...")
|
|
fixedDelay(100)
|
|
e.text("E2E test error: simulated failure")
|
|
}
|
|
|
|
// scenarioSubagent: a claude-style subagent (Task) tool call carrying the
|
|
// `_meta.claudeCode` Agent marker and a toolResponse with full result metrics,
|
|
// so the kandev adapter normalizes it to a subagent_task payload and the UI
|
|
// renders the subagent card with metadata chips.
|
|
func scenarioSubagent(e *emitter) {
|
|
taskToolID := nextToolID()
|
|
fixedDelay(50)
|
|
|
|
e.startSubagentTool(taskToolID,
|
|
"Explore the codebase",
|
|
"Find all files and summarize the project structure",
|
|
"general-purpose")
|
|
|
|
fixedDelay(50)
|
|
e.text("Subagent working on the task...")
|
|
|
|
// A tool call the subagent runs internally, attributed to the Task via
|
|
// `_meta.claudeCode.parentToolUseId` so it nests under the subagent card.
|
|
childToolID := nextToolID()
|
|
e.startChildTool(childToolID, taskToolID, "sleep 30", acp.ToolKindExecute,
|
|
map[string]any{"command": "sleep 30"})
|
|
fixedDelay(50)
|
|
e.completeChildTool(childToolID, taskToolID, map[string]any{"output": ""})
|
|
|
|
fixedDelay(50)
|
|
e.completeSubagentTool(taskToolID, "E2E subagent completed", subagentResult{
|
|
agentID: "agent_e2e_0001",
|
|
subagentType: "general-purpose",
|
|
durationMs: 2200,
|
|
totalTokens: 9987,
|
|
toolUseCount: 3,
|
|
})
|
|
|
|
fixedDelay(50)
|
|
e.text("Subagent scenario complete.")
|
|
}
|
|
|
|
// scenarioAllTools: one of each tool type with fixed delays, using real files.
|
|
func scenarioAllTools(e *emitter) {
|
|
used := map[string]bool{}
|
|
readFile := randomFile()
|
|
used[readFile.absPath] = true
|
|
grepFile := randomFileExcluding(used)
|
|
used[grepFile.absPath] = true
|
|
editFile := randomFileExcluding(used)
|
|
|
|
fixedDelay(50)
|
|
e.thought("Running all tools...")
|
|
|
|
scenarioAllToolsReadGrep(e, readFile, grepFile)
|
|
scenarioAllToolsEditBash(e, editFile)
|
|
|
|
// WebFetch
|
|
fixedDelay(50)
|
|
webID := nextToolID()
|
|
e.startTool(webID, "Fetch example.com", acp.ToolKindFetch,
|
|
map[string]any{"url": "https://example.com", clarificationPromptKey: "Summarize"})
|
|
fixedDelay(50)
|
|
e.completeTool(webID, map[string]any{"content": "Example page content"})
|
|
|
|
fixedDelay(50)
|
|
e.text("All tools scenario complete.")
|
|
}
|
|
|
|
func scenarioAllToolsReadGrep(e *emitter, readFile, grepFile fileInfo) {
|
|
// Read
|
|
fixedDelay(50)
|
|
readID := nextToolID()
|
|
snippet := readFileSnippet(readFile.absPath, 20)
|
|
e.startTool(readID, "Read "+readFile.relPath, acp.ToolKindRead,
|
|
map[string]any{"file_path": readFile.absPath},
|
|
acp.ToolCallLocation{Path: readFile.absPath})
|
|
fixedDelay(50)
|
|
e.completeTool(readID, map[string]any{"content": snippet})
|
|
|
|
// Grep
|
|
fixedDelay(50)
|
|
grepID := nextToolID()
|
|
e.startTool(grepID, "Search for \"func \"", acp.ToolKindSearch,
|
|
map[string]any{"pattern": "func ", "path": grepFile.absPath})
|
|
fixedDelay(50)
|
|
paths := randomFilePaths(3)
|
|
var grepResults []string
|
|
for i, p := range paths {
|
|
grepResults = append(grepResults, fmt.Sprintf("%s:%d: func found here", p, (i+1)*10))
|
|
}
|
|
e.completeTool(grepID, map[string]any{"matches": strings.Join(grepResults, "\n")})
|
|
}
|
|
|
|
func scenarioAllToolsEditBash(e *emitter, editFile fileInfo) {
|
|
// Edit (with permission)
|
|
fixedDelay(50)
|
|
editID := nextToolID()
|
|
oldStr, newStr := pickEditableFragment(editFile.absPath)
|
|
editInput := map[string]any{"file_path": editFile.absPath, "old_string": oldStr, "new_string": newStr}
|
|
e.startTool(editID, "Edit "+editFile.relPath, acp.ToolKindEdit, editInput,
|
|
acp.ToolCallLocation{Path: editFile.absPath})
|
|
allowed := e.requestPermission(editID, "Edit "+editFile.relPath, acp.ToolKindEdit, editInput)
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(editID, map[string]any{toolKeyResult: "File edited successfully: " + editFile.absPath})
|
|
} else {
|
|
e.completeTool(editID, map[string]any{toolKeyResult: "Edit denied"})
|
|
e.text("Edit denied.")
|
|
}
|
|
|
|
// Bash (with permission)
|
|
fixedDelay(50)
|
|
bashID := nextToolID()
|
|
bashInput := map[string]any{"command": "echo done", "description": "Print done"}
|
|
e.startTool(bashID, "Print done", acp.ToolKindExecute, bashInput)
|
|
allowed = e.requestPermission(bashID, "Print done", acp.ToolKindExecute, bashInput)
|
|
fixedDelay(50)
|
|
if allowed {
|
|
e.completeTool(bashID, map[string]any{"output": "done"})
|
|
} else {
|
|
e.completeTool(bashID, map[string]any{"output": "Permission denied"})
|
|
e.text("Bash denied.")
|
|
}
|
|
}
|
|
|
|
// scenarioMultiTurn: minimal response for multi-turn test.
|
|
func scenarioMultiTurn(e *emitter) {
|
|
fixedDelay(50)
|
|
e.text("Multi-turn response ready. Send another message to continue.")
|
|
}
|
|
|
|
// scenarioDiffExpansionSetup: creates a committed file and then modifies it,
|
|
// leaving an uncommitted diff that the UI can display with expansion enabled.
|
|
func scenarioDiffExpansionSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("diff-expansion-setup: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
const totalLines = 200
|
|
originalLines := make([]string, totalLines)
|
|
for i := 0; i < totalLines; i++ {
|
|
originalLines[i] = fmt.Sprintf("func original_%03d() { /* line %d */ }", i+1, i+1)
|
|
}
|
|
original := strings.Join(originalLines, "\n") + "\n"
|
|
|
|
filePath := "expansion_test.go"
|
|
if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil {
|
|
e.text("diff-expansion-setup: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
runGitCmd := makeGitRunner(wd)
|
|
_ = runGitCmd("rm", "--force", filePath)
|
|
_ = runGitCmd("commit", "-m", "cleanup expansion_test.go")
|
|
|
|
if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil {
|
|
e.text("diff-expansion-setup: re-write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
if err := runGitCmd("add", filePath); err != nil {
|
|
e.text("diff-expansion-setup: git add failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("commit", "-m", "add expansion_test.go for e2e diff expansion test"); err != nil {
|
|
e.text("diff-expansion-setup: git commit failed")
|
|
return
|
|
}
|
|
|
|
modifiedLines := make([]string, totalLines)
|
|
copy(modifiedLines, originalLines)
|
|
modifiedLines[49] = "func modified_mid_top() { /* HUNK_TOP - modified line 50 */ }"
|
|
modifiedLines[149] = "func modified_mid_bottom() { /* HUNK_BOTTOM - modified line 150 */ }"
|
|
modified := strings.Join(modifiedLines, "\n") + "\n"
|
|
|
|
if err := os.WriteFile(filePath, []byte(modified), 0o644); err != nil {
|
|
e.text("diff-expansion-setup: write modified failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("diff-expansion-setup complete: expansion_test.go has two-hunk uncommitted diff (lines 50 and 150 of 200).")
|
|
}
|
|
|
|
// scenarioDiffUpdateSetup creates a simple file, commits it, then modifies it.
|
|
func scenarioDiffUpdateSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("diff-update-setup: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
filePath := "diff_update_test.txt"
|
|
originalContent := "line 1: original\nline 2: unchanged\nline 3: original\n"
|
|
|
|
if err := os.WriteFile(filePath, []byte(originalContent), 0o644); err != nil {
|
|
e.text("diff-update-setup: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
runGitCmd := makeGitRunner(wd)
|
|
_ = runGitCmd("rm", "--force", filePath)
|
|
_ = runGitCmd("commit", "-m", "cleanup diff_update_test.txt")
|
|
|
|
if err := os.WriteFile(filePath, []byte(originalContent), 0o644); err != nil {
|
|
e.text("diff-update-setup: re-write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
if err := runGitCmd("add", filePath); err != nil {
|
|
e.text("diff-update-setup: git add failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("commit", "-m", "add diff_update_test.txt"); err != nil {
|
|
e.text("diff-update-setup: git commit failed")
|
|
return
|
|
}
|
|
|
|
modifiedContent := "line 1: FIRST_MODIFICATION\nline 2: unchanged\nline 3: original\n"
|
|
if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil {
|
|
e.text("diff-update-setup: write modified failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("diff-update-setup complete: diff_update_test.txt has FIRST_MODIFICATION")
|
|
}
|
|
|
|
// scenarioDiffUpdateModify modifies the diff_update_test.txt file again.
|
|
func scenarioDiffUpdateModify(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
filePath := "diff_update_test.txt"
|
|
modifiedContent := "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n"
|
|
if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil {
|
|
e.text("diff-update-modify: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("diff-update-modify complete: diff_update_test.txt now has SECOND_MODIFICATION")
|
|
}
|
|
|
|
// scenarioPushCurrentBranch pushes the worktree's current branch to origin
|
|
// directly, with no Kandev "Create MR"/"Create PR" action involved. Used by
|
|
// push-detection auto-link e2e tests to reproduce an agent running a raw
|
|
// `git push` on its own — the same shape as a real user/agent pushing and
|
|
// then opening the MR/PR through `glab mr create`, `gh pr create`, or the
|
|
// code host's web UI, rather than through Kandev's own runtime action.
|
|
// Expects a prior turn (e.g. diff-update-setup) to have already committed
|
|
// something to push.
|
|
//
|
|
// Explicitly writes the remote-tracking ref and upstream config after
|
|
// pushing rather than relying on `git push -u` alone: the e2e fixture's mock
|
|
// git remote only implements the receive-pack (push) side of the smart-HTTP
|
|
// protocol, not upload-pack/info-refs (fetch/clone) — confirmed by
|
|
// worktree-manager's own "git fetch failed before worktree creation;
|
|
// continuing with fallback ref" warning during task setup, and reproducible
|
|
// directly (`git fetch` against it fails with "not valid: is this a git
|
|
// repository?"). A real code host's push response updates
|
|
// refs/remotes/origin/<branch> as a side effect of the exchange; here that
|
|
// has to be reproduced by hand with `update-ref`, using the local HEAD this
|
|
// process just successfully pushed as the known-good value — no fetch
|
|
// required, since the pushed content is exactly what's already local.
|
|
func scenarioPushCurrentBranch(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("push-current-branch: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
branchNameOut, err := runGitOutput(wd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if err != nil {
|
|
e.text("push-current-branch: resolve branch name failed: " + err.Error())
|
|
return
|
|
}
|
|
branchName := strings.TrimSpace(string(branchNameOut))
|
|
headSHAOut, err := runGitOutput(wd, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
e.text("push-current-branch: resolve HEAD sha failed: " + err.Error())
|
|
return
|
|
}
|
|
headSHA := strings.TrimSpace(string(headSHAOut))
|
|
|
|
runGitCmd := makeGitRunner(wd)
|
|
if err := runGitCmd("push", "origin", branchName); err != nil {
|
|
e.text("push-current-branch: git push failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("update-ref", "refs/remotes/origin/"+branchName, headSHA); err != nil {
|
|
e.text("push-current-branch: git update-ref failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("branch", "--set-upstream-to=origin/"+branchName, branchName); err != nil {
|
|
e.text("push-current-branch: git branch --set-upstream-to failed")
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("push-current-branch complete: pushed directly, no Create MR/PR action used")
|
|
}
|
|
|
|
// scenarioDiffUpdateStreaming modifies the file mid-turn, emitting text both
|
|
// before and after the write so the agent's turn stays active for several
|
|
// seconds. Used to assert that open file editor / diff panels auto-update
|
|
// while the agent is still streaming.
|
|
func scenarioDiffUpdateStreaming(e *emitter) {
|
|
fixedDelay(50)
|
|
e.text("diff-update-streaming: starting work")
|
|
|
|
fixedDelay(1000)
|
|
|
|
filePath := "diff_update_test.txt"
|
|
modifiedContent := "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n"
|
|
if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil {
|
|
e.text("diff-update-streaming: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(500)
|
|
e.text("diff-update-streaming: file written, continuing")
|
|
|
|
// Long tail keeps the turn active long enough for polling+sync to fire.
|
|
fixedDelay(6000)
|
|
e.text("diff-update-streaming complete")
|
|
}
|
|
|
|
// scenarioMultiFileSetup commits three tracked files, then modifies all three
|
|
// so each shows up in git status with FIRST_MODIFICATION. Used to test the
|
|
// case where the user has multiple file editors / diff panels open at once.
|
|
func scenarioMultiFileSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("multi-file-setup: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
runGitCmd := makeGitRunner(wd)
|
|
files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"}
|
|
|
|
// Cleanup any leftovers from a prior run.
|
|
for _, f := range files {
|
|
_ = runGitCmd("rm", "--force", f)
|
|
}
|
|
_ = runGitCmd("commit", "-m", "cleanup multi-file fixtures")
|
|
|
|
// Commit pristine versions.
|
|
for _, f := range files {
|
|
original := fmt.Sprintf("%s line 1: original\n%s line 2: unchanged\n%s line 3: original\n", f, f, f)
|
|
if err := os.WriteFile(f, []byte(original), 0o644); err != nil {
|
|
e.text("multi-file-setup: write failed: " + err.Error())
|
|
return
|
|
}
|
|
if err := runGitCmd("add", f); err != nil {
|
|
e.text("multi-file-setup: git add failed for " + f)
|
|
return
|
|
}
|
|
}
|
|
if err := runGitCmd("commit", "-m", "add multi-file fixtures"); err != nil {
|
|
e.text("multi-file-setup: git commit failed")
|
|
return
|
|
}
|
|
|
|
// Modify all three so each appears in git status with FIRST_MODIFICATION.
|
|
for _, f := range files {
|
|
modified := fmt.Sprintf("%s line 1: FIRST_MODIFICATION\n%s line 2: unchanged\n%s line 3: original\n", f, f, f)
|
|
if err := os.WriteFile(f, []byte(modified), 0o644); err != nil {
|
|
e.text("multi-file-setup: write modified failed: " + err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("multi-file-setup complete: 3 files have FIRST_MODIFICATION")
|
|
}
|
|
|
|
// scenarioMultiFileModify modifies all three multi-file fixtures within a
|
|
// single turn, with a long trailing delay so the panels must auto-update
|
|
// while the agent is still streaming. Reproduces the user's reported case
|
|
// where multiple open editor / diff panels go stale on a real edit.
|
|
func scenarioMultiFileModify(e *emitter) {
|
|
fixedDelay(50)
|
|
e.text("multi-file-modify: starting work")
|
|
|
|
fixedDelay(800)
|
|
|
|
files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"}
|
|
for i, f := range files {
|
|
modified := fmt.Sprintf(
|
|
"%s line 1: SECOND_MODIFICATION\n%s line 2: unchanged\n%s line 3: ALSO_CHANGED_%d\n",
|
|
f, f, f, i,
|
|
)
|
|
if err := os.WriteFile(f, []byte(modified), 0o644); err != nil {
|
|
e.text("multi-file-modify: write failed: " + err.Error())
|
|
return
|
|
}
|
|
// Stagger the writes slightly to mimic a real agent doing one tool
|
|
// call per file rather than all writes in a single instant.
|
|
fixedDelay(250)
|
|
}
|
|
|
|
e.text("multi-file-modify: writes done, continuing")
|
|
|
|
// Long tail keeps the turn active so we can assert mid-turn updates.
|
|
fixedDelay(5000)
|
|
e.text("multi-file-modify complete")
|
|
}
|
|
|
|
// scenarioUntrackedFileSetup creates a new untracked file.
|
|
func scenarioUntrackedFileSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
filePath := "untracked_test.txt"
|
|
content := "line 1: INITIAL_CONTENT\nline 2: some text\n"
|
|
|
|
_ = os.Remove(filePath)
|
|
|
|
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
|
e.text("untracked-file-setup: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("untracked-file-setup complete: untracked_test.txt has INITIAL_CONTENT")
|
|
}
|
|
|
|
// scenarioUntrackedFileModify modifies the untracked file.
|
|
func scenarioUntrackedFileModify(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
filePath := "untracked_test.txt"
|
|
content := "line 1: MODIFIED_CONTENT\nline 2: some text\nline 3: NEW_LINE\n"
|
|
|
|
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
|
e.text("untracked-file-modify: write failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("untracked-file-modify complete: untracked_test.txt now has MODIFIED_CONTENT")
|
|
}
|
|
|
|
// MCP argument keys used by the clarification scenarios. Pulled out so goconst
|
|
// stays happy when the same string would otherwise repeat across both helpers.
|
|
const (
|
|
clarificationOptionsKey = "options"
|
|
clarificationLabelKey = "label"
|
|
clarificationDescKey = "description"
|
|
clarificationPromptKey = "prompt"
|
|
clarificationIDKey = "id"
|
|
)
|
|
|
|
func mockOption(label, description string) map[string]any {
|
|
return map[string]any{clarificationLabelKey: label, clarificationDescKey: description}
|
|
}
|
|
|
|
// clarificationQuestionArgs returns the MCP arguments for a single-question
|
|
// clarification call. Used by the simplest e2e scenario where a one-question
|
|
// bundle exercises the same code path as multi-question.
|
|
func clarificationQuestionArgs() map[string]any {
|
|
return map[string]any{
|
|
"questions": []map[string]any{
|
|
{
|
|
clarificationIDKey: "db",
|
|
clarificationPromptKey: "Which database should we use for this project?",
|
|
clarificationOptionsKey: []map[string]any{
|
|
mockOption("PostgreSQL", "Relational database with strong consistency"),
|
|
mockOption("MongoDB", "Document database for flexible schemas"),
|
|
mockOption("SQLite", "Embedded database for simplicity"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// clarificationMultiQuestionArgs returns the MCP arguments for a 3-question
|
|
// clarification bundle that exercises the multi-question UI flow.
|
|
func clarificationMultiQuestionArgs() map[string]any {
|
|
return map[string]any{
|
|
"questions": []map[string]any{
|
|
{
|
|
clarificationIDKey: "db",
|
|
clarificationPromptKey: "Which database should we use?",
|
|
clarificationOptionsKey: []map[string]any{
|
|
mockOption("PostgreSQL", "Relational database with strong consistency"),
|
|
mockOption("MongoDB", "Document database for flexible schemas"),
|
|
mockOption("SQLite", "Embedded database for simplicity"),
|
|
},
|
|
},
|
|
{
|
|
clarificationIDKey: "language",
|
|
clarificationPromptKey: "Which language should we use?",
|
|
clarificationOptionsKey: []map[string]any{
|
|
mockOption("Go", "Strong typing, fast compile"),
|
|
mockOption("TypeScript", "Familiar to the team"),
|
|
mockOption("Rust", "Best for systems work"),
|
|
},
|
|
},
|
|
{
|
|
clarificationIDKey: "deploy",
|
|
clarificationPromptKey: "How should we deploy?",
|
|
clarificationOptionsKey: []map[string]any{
|
|
mockOption("Docker", "Containerized deploy"),
|
|
mockOption("Bare metal", "Run directly on hosts"),
|
|
},
|
|
},
|
|
},
|
|
"context": "Picking the foundational stack: answer all three so we can move forward.",
|
|
}
|
|
}
|
|
|
|
// scenarioClarification: happy path — ask a question via MCP and wait for the answer.
|
|
func scenarioClarification(e *emitter) {
|
|
fixedDelay(100)
|
|
e.text("Let me ask you a question about the project setup.")
|
|
|
|
result, err := callMCPTool("kandev", "ask_user_question_kandev", clarificationQuestionArgs())
|
|
if err != nil {
|
|
e.text(fmt.Sprintf("Question failed: %s", err))
|
|
return
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text(fmt.Sprintf("You answered: %s", result))
|
|
}
|
|
|
|
// scenarioClarificationMulti: stress the multi-question path — 3 questions in
|
|
// one bundle, all required, single MCP call.
|
|
func scenarioClarificationMulti(e *emitter) {
|
|
fixedDelay(100)
|
|
e.text("Let me ask you a few questions about the project setup.")
|
|
|
|
result, err := callMCPTool("kandev", "ask_user_question_kandev", clarificationMultiQuestionArgs())
|
|
if err != nil {
|
|
e.text(fmt.Sprintf("Questions failed: %s", err))
|
|
return
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text(fmt.Sprintf("You answered: %s", result))
|
|
}
|
|
|
|
// scenarioClarificationTimeout: ask a question with a short timeout, then continue.
|
|
func scenarioClarificationTimeout(e *emitter) {
|
|
fixedDelay(100)
|
|
e.text("Let me ask you a question about the project setup.")
|
|
|
|
ctx, cancel := contextWithTimeout(5)
|
|
defer cancel()
|
|
|
|
result, err := callMCPToolCtx(ctx, "kandev", "ask_user_question_kandev", clarificationQuestionArgs())
|
|
if err != nil {
|
|
fixedDelay(50)
|
|
if ctx.Err() != nil {
|
|
e.text("Question timed out, continuing without answer.")
|
|
} else {
|
|
e.text(fmt.Sprintf("Question failed: %s", err))
|
|
}
|
|
return
|
|
}
|
|
|
|
fixedDelay(50)
|
|
e.text(fmt.Sprintf("You answered: %s", result))
|
|
}
|
|
|
|
// scenarioReviewCumulativeSetup creates a file, commits it, then modifies and
|
|
// commits again, then makes a final uncommitted modification. This produces a
|
|
// file with both committed and uncommitted changes relative to the session's
|
|
// base commit, exercising the cumulative diff (base → working tree).
|
|
func scenarioReviewCumulativeSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("review-cumulative-setup: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
filePath := "review_cumulative_test.txt"
|
|
baseContent := "line 1: BASE_CONTENT\nline 2: unchanged\nline 3: BASE_CONTENT\n"
|
|
|
|
// Clean up any leftover file from a previous run.
|
|
runGitCmd := makeGitRunner(wd)
|
|
_ = runGitCmd("rm", "--force", filePath)
|
|
_ = runGitCmd("commit", "-m", "cleanup "+filePath)
|
|
|
|
// Write original content and commit.
|
|
if err := os.WriteFile(filePath, []byte(baseContent), 0o644); err != nil {
|
|
e.text("review-cumulative-setup: write base failed: " + err.Error())
|
|
return
|
|
}
|
|
if err := runGitCmd("add", filePath); err != nil {
|
|
e.text("review-cumulative-setup: git add base failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("commit", "-m", "add "+filePath+" with base content"); err != nil {
|
|
e.text("review-cumulative-setup: git commit base failed")
|
|
return
|
|
}
|
|
|
|
// Second commit: modify the file.
|
|
committedContent := "line 1: COMMITTED_CHANGE\nline 2: unchanged\nline 3: BASE_CONTENT\n"
|
|
if err := os.WriteFile(filePath, []byte(committedContent), 0o644); err != nil {
|
|
e.text("review-cumulative-setup: write committed failed: " + err.Error())
|
|
return
|
|
}
|
|
if err := runGitCmd("add", filePath); err != nil {
|
|
e.text("review-cumulative-setup: git add committed failed")
|
|
return
|
|
}
|
|
if err := runGitCmd("commit", "-m", "modify "+filePath+" with committed change"); err != nil {
|
|
e.text("review-cumulative-setup: git commit committed failed")
|
|
return
|
|
}
|
|
|
|
// Leave an uncommitted modification on top.
|
|
uncommittedContent := "line 1: COMMITTED_CHANGE\nline 2: unchanged\nline 3: UNCOMMITTED_CHANGE\n"
|
|
if err := os.WriteFile(filePath, []byte(uncommittedContent), 0o644); err != nil {
|
|
e.text("review-cumulative-setup: write uncommitted failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("review-cumulative-setup complete: " + filePath + " has COMMITTED_CHANGE and UNCOMMITTED_CHANGE")
|
|
}
|
|
|
|
// walkthroughDemoArgs is the show_walkthrough_kandev payload used by
|
|
// scenarioWalkthroughBasic. task_id is resolved server-side from the session's
|
|
// env (KANDEV_TASK_ID), mirroring the clarification path, so it is omitted here.
|
|
// Walkthrough step JSON keys, hoisted to constants to satisfy goconst.
|
|
const (
|
|
wtKeyTitle = "title"
|
|
wtKeyFile = "file"
|
|
wtKeyLine = "line"
|
|
wtKeyLineEnd = "line_end"
|
|
wtKeyText = "text"
|
|
wtKeySteps = "steps"
|
|
)
|
|
|
|
// wtStep builds one show_walkthrough step map. Keeping the JSON keys in one
|
|
// place avoids repeating string literals (goconst).
|
|
func wtStep(title, file, text string, line, lineEnd int) map[string]interface{} {
|
|
m := map[string]interface{}{wtKeyTitle: title, wtKeyFile: file, wtKeyLine: line, wtKeyText: text}
|
|
if lineEnd > 0 {
|
|
m[wtKeyLineEnd] = lineEnd
|
|
}
|
|
return m
|
|
}
|
|
|
|
// wtArgs assembles show_walkthrough_kandev args from a title and steps.
|
|
func wtArgs(title string, steps ...map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{wtKeyTitle: title, wtKeySteps: steps}
|
|
}
|
|
|
|
// scenarioWalkthroughReemit emits one walkthrough, waits, then emits a second
|
|
// (different) one — exercising the live `task.walkthrough.updated` path so the
|
|
// UI must reflect the re-emit without a page reload.
|
|
func scenarioWalkthroughReemit(e *emitter) {
|
|
fixedDelay(50)
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("walkthrough-reemit: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
runGitCmd := makeGitRunner(wd)
|
|
if !writeWalkthroughFile(e, runGitCmd, "walkthrough-reemit", "reemit.txt", "line 1\nline 2\n", "line 1\nREEMIT_CHANGE\n") {
|
|
return
|
|
}
|
|
|
|
e.text("First tour incoming.")
|
|
if _, err := callMCPTool("kandev", "show_walkthrough_kandev", wtArgs("First",
|
|
wtStep("First step", "reemit.txt", "REEMIT_FIRST step one.", 1, 0),
|
|
wtStep("First step 2", "reemit.txt", "REEMIT_FIRST step two.", 2, 0),
|
|
)); err != nil {
|
|
e.text(fmt.Sprintf("show_walkthrough failed: %s", err))
|
|
return
|
|
}
|
|
e.text("reemit-first-done")
|
|
|
|
fixedDelay(200)
|
|
|
|
if _, err := callMCPTool("kandev", "show_walkthrough_kandev", wtArgs("Second",
|
|
wtStep("Second step", "reemit.txt", "REEMIT_SECOND step one.", 1, 0),
|
|
wtStep("Second step 2", "reemit.txt", "REEMIT_SECOND step two.", 2, 0),
|
|
wtStep("Second step 3", "reemit.txt", "REEMIT_SECOND step three.", 1, 0),
|
|
)); err != nil {
|
|
e.text(fmt.Sprintf("show_walkthrough re-emit failed: %s", err))
|
|
return
|
|
}
|
|
e.text("reemit-second-done")
|
|
}
|
|
|
|
// walkthroughDemoArgs builds a 5-step tour spanning three changed files plus
|
|
// one clean committed file (exercising both the diff-anchored card and the
|
|
// full-file / editor-mode floating window).
|
|
func walkthroughDemoArgs() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
wtKeyTitle: "Tour of the change",
|
|
wtKeySteps: []map[string]interface{}{
|
|
wtStep("Overview", "walkthrough_a.txt",
|
|
"Big picture: this tour explains how the sample changes connect across the touched files.\n\nELI5: first we show the map, then we explain each changed line.", 1, 0),
|
|
wtStep("The change in A", "walkthrough_a.txt",
|
|
"Step 2: this is WALKTHROUGH_CHANGE_A the tour narrates.", 2, 3),
|
|
wtStep("File B", "walkthrough_b.txt",
|
|
"Step 3: WALKTHROUGH_CHANGE_B lives in file B.", 4, 5),
|
|
wtStep("File C", "walkthrough_c.txt",
|
|
"Step 4: WALKTHROUGH_CHANGE_C lives in file C.", 2, 0),
|
|
wtStep("Unchanged file", "walkthrough_base.txt",
|
|
"Step 5: WALKTHROUGH_UNCHANGED: this base file did not change; shown from its current state.", 1, 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
// writeWalkthroughFile writes content and commits it, then (when changed is
|
|
// non-empty) leaves an uncommitted modification so the file appears in review.
|
|
func writeWalkthroughFile(e *emitter, runGitCmd func(args ...string) error, prefix, path, base, changed string) bool {
|
|
if err := os.WriteFile(path, []byte(base), 0o644); err != nil {
|
|
e.text(prefix + ": write base failed: " + err.Error())
|
|
return false
|
|
}
|
|
if err := runGitCmd("add", path); err != nil {
|
|
e.text(prefix + ": git add failed: " + err.Error())
|
|
return false
|
|
}
|
|
if err := runGitCmd("commit", "-m", "add "+path); err != nil {
|
|
e.text(prefix + ": git commit failed: " + err.Error())
|
|
return false
|
|
}
|
|
if changed == "" {
|
|
return true
|
|
}
|
|
if err := os.WriteFile(path, []byte(changed), 0o644); err != nil {
|
|
e.text(prefix + ": write change failed: " + err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func setupWalkthroughFiles(e *emitter, prefix string) bool {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text(prefix + ": getwd failed: " + err.Error())
|
|
return false
|
|
}
|
|
runGitCmd := makeGitRunner(wd)
|
|
|
|
fixtures := []string{"walkthrough_a.txt", "walkthrough_b.txt", "walkthrough_c.txt"}
|
|
_ = runGitCmd(append([]string{"rm", "--force"}, fixtures...)...)
|
|
_ = runGitCmd("commit", "-m", "cleanup walkthrough fixtures")
|
|
|
|
// Three changed files (diff-anchored steps) + one clean committed file (editor-mode step).
|
|
ok := writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_a.txt",
|
|
"line 1: ENTRY\nline 2: BASE\nline 3: BASE\n",
|
|
"line 1: ENTRY\nline 2: WALKTHROUGH_CHANGE_A\nline 3: WALKTHROUGH_CHANGE_A\n")
|
|
ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_b.txt",
|
|
"line 1: B\nline 2: BASE\nline 3: BASE\nline 4: BASE\nline 5: BASE\n",
|
|
"line 1: B\nline 2: BASE\nline 3: BASE\nline 4: WALKTHROUGH_CHANGE_B\nline 5: WALKTHROUGH_CHANGE_B\n")
|
|
ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_c.txt",
|
|
"line 1: C\nline 2: BASE\n", "line 1: C\nline 2: WALKTHROUGH_CHANGE_C\n")
|
|
const baseContent = "line 1: WALKTHROUGH_UNCHANGED\nline 2: seeded on main\n"
|
|
if !walkthroughBaseIsClean(runGitCmd, baseContent) {
|
|
_ = runGitCmd("rm", "--force", "walkthrough_base.txt")
|
|
_ = runGitCmd("commit", "-m", "cleanup walkthrough base fixture")
|
|
ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_base.txt", baseContent, "")
|
|
}
|
|
return ok
|
|
}
|
|
|
|
func walkthroughBaseIsClean(runGitCmd func(args ...string) error, expectedContent string) bool {
|
|
content, err := os.ReadFile("walkthrough_base.txt")
|
|
return err == nil &&
|
|
string(content) == expectedContent &&
|
|
runGitCmd("ls-files", "--error-unmatch", "walkthrough_base.txt") == nil &&
|
|
runGitCmd("diff", "--quiet", "--", "walkthrough_base.txt") == nil &&
|
|
runGitCmd("diff", "--cached", "--quiet", "--", "walkthrough_base.txt") == nil
|
|
}
|
|
|
|
func emitWalkthroughTour(e *emitter, doneText string) {
|
|
fixedDelay(100)
|
|
e.text("Let me walk you through the change.")
|
|
toolID := nextToolID()
|
|
toolName := "show_walkthrough_kandev"
|
|
args := walkthroughDemoArgs()
|
|
e.startTool(toolID, toolName, acp.ToolKindOther, args)
|
|
result, err := callMCPTool("kandev", toolName, args)
|
|
if err != nil {
|
|
e.completeTool(toolID, map[string]any{toolKeyError: "MCP error: " + err.Error()})
|
|
e.text(fmt.Sprintf("show_walkthrough failed: %s", err))
|
|
return
|
|
}
|
|
e.completeTool(toolID, map[string]any{toolKeyResult: result})
|
|
fixedDelay(50)
|
|
e.text(doneText)
|
|
}
|
|
|
|
// scenarioWalkthroughSetup creates changed files without emitting a walkthrough.
|
|
// It lets E2E tests click the actual Changes-panel Walkthrough request button.
|
|
func scenarioWalkthroughSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
if !setupWalkthroughFiles(e, "walkthrough-setup") {
|
|
return
|
|
}
|
|
e.text("walkthrough-setup complete: changes ready")
|
|
}
|
|
|
|
// scenarioWalkthroughRequested is the mock-agent response to the prompt generated
|
|
// by the Changes-panel Walkthrough button.
|
|
func scenarioWalkthroughRequested(e *emitter) {
|
|
emitWalkthroughTour(e, "walkthrough-request complete: 5-step tour emitted")
|
|
}
|
|
|
|
// scenarioWalkthroughBasic creates changed files, then emits a 5-step code
|
|
// walkthrough over them via the show_walkthrough_kandev MCP tool.
|
|
func scenarioWalkthroughBasic(e *emitter) {
|
|
fixedDelay(50)
|
|
if !setupWalkthroughFiles(e, "walkthrough-basic") {
|
|
return
|
|
}
|
|
emitWalkthroughTour(e, "walkthrough-basic complete: 5-step tour emitted")
|
|
}
|
|
|
|
// scenarioSymlinkFileSetup creates a file and a symlink to it, commits both,
|
|
// then modifies the target file leaving an uncommitted diff.
|
|
func scenarioSymlinkFileSetup(e *emitter) {
|
|
fixedDelay(50)
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
e.text("symlink-file-setup: getwd failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
runGitCmd := makeGitRunner(wd)
|
|
|
|
// Create target file and commit it.
|
|
targetFile := "real-file.txt"
|
|
if err := os.WriteFile(targetFile, []byte("Hello from symlink target!\n"), 0o644); err != nil {
|
|
e.text("symlink-file-setup: write target failed: " + err.Error())
|
|
return
|
|
}
|
|
_ = runGitCmd("add", targetFile)
|
|
_ = runGitCmd("commit", "-m", "add real-file.txt")
|
|
|
|
// Create symlink and commit it.
|
|
linkFile := "link-file.txt"
|
|
_ = os.Remove(linkFile) // clean up if leftover
|
|
if err := os.Symlink(targetFile, linkFile); err != nil {
|
|
e.text("symlink-file-setup: symlink failed: " + err.Error())
|
|
return
|
|
}
|
|
_ = runGitCmd("add", linkFile)
|
|
_ = runGitCmd("commit", "-m", "add link-file.txt symlink")
|
|
|
|
// Modify target file, leaving an uncommitted diff.
|
|
if err := os.WriteFile(targetFile, []byte("Modified symlink target content!\n"), 0o644); err != nil {
|
|
e.text("symlink-file-setup: write modified failed: " + err.Error())
|
|
return
|
|
}
|
|
|
|
fixedDelay(100)
|
|
e.text("symlink-file-setup complete")
|
|
}
|
|
|
|
func runGitOutput(wd string, args ...string) ([]byte, error) {
|
|
cmd := subproc.NewGitCommand(context.Background(), append([]string{"-C", wd}, args...)...)
|
|
return subproc.RunGitOutputClass(context.Background(), subproc.GitLifecycle, cmd)
|
|
}
|
|
|
|
// makeGitRunner returns a function that runs git commands in the given directory.
|
|
func makeGitRunner(wd string) func(args ...string) error {
|
|
gitEnv := append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=Mock Agent",
|
|
"GIT_AUTHOR_EMAIL=mock@test.local",
|
|
"GIT_COMMITTER_NAME=Mock Agent",
|
|
"GIT_COMMITTER_EMAIL=mock@test.local",
|
|
)
|
|
return func(args ...string) error {
|
|
cmd := subproc.NewGitCommand(context.Background(), append([]string{
|
|
"-c", "commit.gpgsign=false",
|
|
"-c", "tag.gpgsign=false",
|
|
}, args...)...)
|
|
cmd.Dir = wd
|
|
cmd.Env = gitEnv
|
|
out, cmdErr := subproc.RunGitCombinedOutputClass(context.Background(), subproc.GitLifecycle, cmd)
|
|
if cmdErr != nil {
|
|
_, _ = fmt.Fprintf(logOutput, "mock-agent: git %v failed: %v\nOutput: %s\n", args, cmdErr, out)
|
|
}
|
|
return cmdErr
|
|
}
|
|
}
|
|
|
|
// contextWithTimeout creates a context with timeout in seconds.
|
|
func contextWithTimeout(seconds int) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), time.Duration(seconds)*time.Second)
|
|
}
|
|
|
|
// scenarioMarkdownTable: emits a dense label/value markdown table modeled on a
|
|
// real agent bug-report summary. The table has narrow header labels in column 1
|
|
// and long inline-code identifiers in column 2 — the shape that previously
|
|
// caused header words to wrap character-by-character ("Failin g test") because
|
|
// the value column starved the label column.
|
|
func scenarioMarkdownTable(e *emitter) {
|
|
fixedDelay(100)
|
|
e.text("Pushed.\n\n" +
|
|
"## Summary\n\n" +
|
|
"| | |\n" +
|
|
"|---|---|\n" +
|
|
"| **Failing test** | `TestHandleAgentBootReady_DrainsOrphanedQueuedMessage/already_WAITING_FOR_INPUT_(boot_raced_persistResumeState)` |\n" +
|
|
"| **Symptom** | `session.State = \"RUNNING\", want WAITING_FOR_INPUT` |\n" +
|
|
"| **Root cause** | Pre-existing race, not introduced by this PR. `handleAgentBootReady` synchronously flips state to `WAITING_FOR_INPUT` then spawns a goroutine that calls `PromptTask` and flips state to `RUNNING`. The test asserted on `WAITING_FOR_INPUT` immediately after the handler returned. On faster CI scheduling the goroutine wins the race. The kandev-ci container apparently schedules tighter than the github-hosted ubuntu, so it loses where the previous env got lucky. |\n" +
|
|
"| **Fix** | Cherry-picked `b8d06ea8 test(backend): fix race in TestHandleAgentBootReady_DrainsOrphanedQueuedMessage` from `feature/subtask-with-repo-se-vhz` (a parallel branch that already addressed this). The test now accepts either `WAITING_FOR_INPUT` or `RUNNING`. Both prove the boot-ready flip landed and rule out the original `STARTING + queue still full` regression. |\n" +
|
|
"| **Local verification** | `go test -race -count=5` → 5/5 PASS. |\n")
|
|
}
|