From 975f358b7ad3fcc9a131bd1b551185944a7a89db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Fri, 8 May 2026 20:52:41 +0800 Subject: [PATCH 01/18] feat: add configurable agent execution working directory Add execution_cwd column to issues, allowing users to specify an absolute path as the agent's working directory instead of the default isolated worktree. Includes a folder picker (Browse button) on desktop. --- packages/core/types/api.ts | 2 + packages/core/types/issue.ts | 1 + packages/views/locales/en/modals.json | 3 + packages/views/locales/zh-Hans/modals.json | 3 + packages/views/modals/create-issue.tsx | 34 ++++++++++++ server/internal/daemon/daemon.go | 33 +++++++---- server/internal/daemon/execenv/execenv.go | 23 ++++++++ server/internal/daemon/types.go | 1 + server/internal/handler/agent.go | 1 + server/internal/handler/daemon.go | 3 + server/internal/handler/issue.go | 24 ++++++++ .../070_issue_execution_cwd.down.sql | 2 + .../migrations/070_issue_execution_cwd.up.sql | 5 ++ server/pkg/db/generated/issue.sql.go | 55 ++++++++++++++----- server/pkg/db/generated/models.go | 1 + server/pkg/db/queries/issue.sql | 18 ++++-- 16 files changed, 176 insertions(+), 33 deletions(-) create mode 100644 server/migrations/070_issue_execution_cwd.down.sql create mode 100644 server/migrations/070_issue_execution_cwd.up.sql diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 3b94696329..6bbbd3cf9d 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -14,6 +14,7 @@ export interface CreateIssueRequest { project_id?: string; due_date?: string; attachment_ids?: string[]; + execution_cwd?: string; } export interface UpdateIssueRequest { @@ -27,6 +28,7 @@ export interface UpdateIssueRequest { due_date?: string | null; parent_issue_id?: string | null; project_id?: string | null; + execution_cwd?: string | null; } export interface ListIssuesParams { diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index 78b43b9cd9..f597f98cac 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -40,6 +40,7 @@ export interface Issue { project_id: string | null; position: number; due_date: string | null; + execution_cwd?: string | null; reactions?: IssueReaction[]; labels?: Label[]; created_at: string; diff --git a/packages/views/locales/en/modals.json b/packages/views/locales/en/modals.json index cf002eb950..4a4d13e0de 100644 --- a/packages/views/locales/en/modals.json +++ b/packages/views/locales/en/modals.json @@ -113,6 +113,9 @@ "title": "Add sub-issue", "description": "Search for an issue to add as a sub-issue of the new issue" }, + "execution_cwd_label": "Working directory", + "execution_cwd_placeholder": "Default: isolated workspace (absolute path)", + "browse": "Browse", "agent": { "created_by": "Created by", "select_agent_aria": "Select agent", diff --git a/packages/views/locales/zh-Hans/modals.json b/packages/views/locales/zh-Hans/modals.json index 92cd723b15..9df882a0da 100644 --- a/packages/views/locales/zh-Hans/modals.json +++ b/packages/views/locales/zh-Hans/modals.json @@ -113,6 +113,9 @@ "title": "添加子 issue", "description": "搜索一个 issue 设为子级" }, + "execution_cwd_label": "工作目录", + "execution_cwd_placeholder": "默认:隔离工作空间(绝对路径)", + "browse": "浏览", "agent": { "created_by": "创建者", "select_agent_aria": "选择智能体", diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 4ab2d9dafc..ed084952c5 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -114,6 +114,7 @@ export function ManualCreatePanel({ // object, and we never need to hydrate from an ID the way we do for parent. const [childIssues, setChildIssues] = useState([]); const [childPickerOpen, setChildPickerOpen] = useState(false); + const [executionCwd, setExecutionCwd] = useState(""); // Fetch parent issue details for the chip (status/identifier/title). // List cache usually has it already, so this resolves synchronously. const wsId = useWorkspaceId(); @@ -155,6 +156,7 @@ export function ManualCreatePanel({ setParentIssueId(undefined); setChildIssues([]); setAttachmentIds([]); + setExecutionCwd(""); setDraft({ title: "", description: "", @@ -183,6 +185,7 @@ export function ManualCreatePanel({ attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, parent_issue_id: parentIssueId, project_id: projectId, + execution_cwd: executionCwd || undefined, }); // Link queued children to the new parent. Deferred to after create @@ -537,6 +540,37 @@ export function ManualCreatePanel({ }} /> + {/* Execution working directory override */} +
+
+ + {t(($) => $.create_issue.execution_cwd_label)} + + setExecutionCwd(e.target.value)} + placeholder={t(($) => $.create_issue.execution_cwd_placeholder)} + className="flex-1 min-w-0 h-7 px-2 text-xs rounded-sm border bg-background text-foreground placeholder:text-muted-foreground/50" + /> + {typeof window !== "undefined" && "daemonAPI" in window && (window as any).daemonAPI?.pickDirectory ? ( + + ) : null} +
+
+ {/* Footer */}
diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index d2dc8e55b4..40e8a42c9d 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1586,21 +1586,26 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i // can't reclaim artifacts inside them mid-execution. We mark both the // predicted root for a fresh Prepare and the prior root for Reuse — they // usually differ (Reuse keeps the original task's directory). - predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID) - d.markActiveEnvRoot(predictedRoot) - defer d.unmarkActiveEnvRoot(predictedRoot) - if task.PriorWorkDir != "" { - priorRoot := filepath.Dir(task.PriorWorkDir) - if priorRoot != predictedRoot { - d.markActiveEnvRoot(priorRoot) - defer d.unmarkActiveEnvRoot(priorRoot) + // When ExecutionCwd is set, there is no isolated env to protect — skip GC + // marking entirely. + if task.ExecutionCwd == "" { + predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID) + d.markActiveEnvRoot(predictedRoot) + defer d.unmarkActiveEnvRoot(predictedRoot) + if task.PriorWorkDir != "" { + priorRoot := filepath.Dir(task.PriorWorkDir) + if priorRoot != predictedRoot { + d.markActiveEnvRoot(priorRoot) + defer d.unmarkActiveEnvRoot(priorRoot) + } } } // Try to reuse the workdir from a previous task on the same (agent, issue) pair. + // When ExecutionCwd is set, skip reuse — the user-specified directory is authoritative. var env *execenv.Environment codexVersion := d.agentVersion("codex") - if task.PriorWorkDir != "" { + if task.ExecutionCwd == "" && task.PriorWorkDir != "" { env = execenv.Reuse(task.PriorWorkDir, provider, codexVersion, taskCtx, d.logger) } if env == nil { @@ -1612,6 +1617,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i AgentName: agentName, Provider: provider, CodexVersion: codexVersion, + ExecutionCWD: task.ExecutionCwd, Task: taskCtx, }, d.logger) if err != nil { @@ -1620,14 +1626,17 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i } // Belt-and-suspenders: also mark whatever root we ended up with, in case // future changes diverge from PredictRootDir. - if env.RootDir != predictedRoot && env.RootDir != "" { + if env.RootDir != "" && task.ExecutionCwd == "" { d.markActiveEnvRoot(env.RootDir) defer d.unmarkActiveEnvRoot(env.RootDir) } // Inject runtime-specific config (meta skill) so the agent discovers .agent_context/. - if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil { - d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err) + // Skip when using a user-specified cwd to avoid overwriting existing CLAUDE.md / AGENTS.md. + if task.ExecutionCwd == "" { + if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil { + d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err) + } } // NOTE: No cleanup — workdir is preserved for reuse by future tasks on // the same (agent, issue) pair. The work_dir path is stored in DB on diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 70454f3a01..e57fb2fb17 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -37,6 +37,7 @@ type PrepareParams struct { AgentName string // for git branch naming only Provider string // agent provider (determines runtime config and skill injection paths) CodexVersion string // detected Codex CLI version (only used when Provider == "codex") + ExecutionCWD string // absolute path override — when set, skip isolated env creation and use this as WorkDir Task TaskContextForEnv // context data for writing files } @@ -100,7 +101,29 @@ func PredictRootDir(workspacesRoot, workspaceID, taskID string) string { // Prepare creates an isolated execution environment for a task. // The workdir starts empty (no repo checkouts). The agent checks out repos // on demand via `multica repo checkout `. +// +// When params.ExecutionCWD is set, directory creation is skipped and the +// specified path is used as WorkDir directly. Context files are still written +// so the agent has issue metadata. func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { + // ExecutionCWD override: use the user-specified directory directly. + if params.ExecutionCWD != "" { + env := &Environment{ + RootDir: "", + WorkDir: params.ExecutionCWD, + logger: logger, + } + + // Write context files into the specified directory so the agent + // can discover the issue, skills, and project resources. + if err := writeContextFiles(params.ExecutionCWD, params.Provider, params.Task); err != nil { + return nil, fmt.Errorf("execenv: write context files to %s: %w", params.ExecutionCWD, err) + } + + logger.Info("execenv: using override cwd", "workdir", params.ExecutionCWD, "repos_available", len(params.Task.Repos)) + return env, nil + } + if params.WorkspacesRoot == "" { return nil, fmt.Errorf("execenv: workspaces root is required") } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 12b9160e40..adb6044976 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -58,6 +58,7 @@ type Task struct { AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks + ExecutionCwd string `json:"execution_cwd,omitempty"` // per-issue working directory override } // AgentData holds agent details returned by the claim endpoint. diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index dfc98e00c3..a1181d8da4 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -172,6 +172,7 @@ type AgentTaskResponse struct { AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs + ExecutionCwd string `json:"execution_cwd,omitempty"` // per-issue working directory override — when set, daemon uses this path as cwd instead of creating an isolated workdir QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue } diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 9a9acfaf00..0ca7f37556 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1015,6 +1015,9 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { if task.IssueID.Valid { if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil { resp.WorkspaceID = uuidToString(issue.WorkspaceID) + if issue.ExecutionCwd.Valid { + resp.ExecutionCwd = issue.ExecutionCwd.String + } var projectRepos []RepoData if issue.ProjectID.Valid { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 6db2f1b1d1..21a8071ff9 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -45,6 +45,7 @@ type IssueResponse struct { UpdatedAt string `json:"updated_at"` Reactions []IssueReactionResponse `json:"reactions,omitempty"` Attachments []AttachmentResponse `json:"attachments,omitempty"` + ExecutionCwd *string `json:"execution_cwd,omitempty"` // Labels are bulk-attached by list/detail endpoints so the client can render // chips without an N+1 round-trip per row. Pointer + omitempty so paths that // don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated @@ -75,6 +76,7 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), + ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -104,6 +106,7 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), + ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -159,6 +162,7 @@ func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueRes DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), + ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -1058,6 +1062,10 @@ type CreateIssueRequest struct { ProjectID *string `json:"project_id"` DueDate *string `json:"due_date"` AttachmentIDs []string `json:"attachment_ids,omitempty"` + // ExecutionCwd overrides the default isolated workdir. When set, the daemon + // uses this absolute path as the agent's working directory instead of + // creating a new isolated environment. + ExecutionCwd *string `json:"execution_cwd,omitempty"` // OriginType / OriginID stamp the new issue with its provenance so // platform-internal flows can deterministically locate it later. Only // trusted callers should set these — currently the daemon CLI passes @@ -1162,6 +1170,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { dueDate = pgtype.Timestamptz{Time: t, Valid: true} } + var executionCwd pgtype.Text + if req.ExecutionCwd != nil && *req.ExecutionCwd != "" { + executionCwd = pgtype.Text{String: *req.ExecutionCwd, Valid: true} + } + // Use a transaction to atomically increment the workspace issue counter // and create the issue with the assigned number. tx, err := h.TxStarter.Begin(r.Context()) @@ -1227,6 +1240,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { ProjectID: projectID, OriginType: originType, OriginID: originID, + ExecutionCwd: executionCwd, }) } else { issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{ @@ -1244,6 +1258,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { DueDate: dueDate, Number: issueNumber, ProjectID: projectID, + ExecutionCwd: executionCwd, }) } if err != nil { @@ -1303,6 +1318,7 @@ type UpdateIssueRequest struct { DueDate *string `json:"due_date"` ParentIssueID *string `json:"parent_issue_id"` ProjectID *string `json:"project_id"` + ExecutionCwd *string `json:"execution_cwd"` } func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { @@ -1339,6 +1355,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { DueDate: prevIssue.DueDate, ParentIssueID: prevIssue.ParentIssueID, ProjectID: prevIssue.ProjectID, + ExecutionCwd: prevIssue.ExecutionCwd, } // COALESCE fields — only set when explicitly provided @@ -1388,6 +1405,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date } } + if _, ok := rawFields["execution_cwd"]; ok { + if req.ExecutionCwd != nil && *req.ExecutionCwd != "" { + params.ExecutionCwd = pgtype.Text{String: *req.ExecutionCwd, Valid: true} + } else { + params.ExecutionCwd = pgtype.Text{Valid: false} // explicit null = clear override + } + } if _, ok := rawFields["parent_issue_id"]; ok { if req.ParentIssueID != nil { newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id") diff --git a/server/migrations/070_issue_execution_cwd.down.sql b/server/migrations/070_issue_execution_cwd.down.sql new file mode 100644 index 0000000000..be4b959a9e --- /dev/null +++ b/server/migrations/070_issue_execution_cwd.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE issue +DROP COLUMN IF EXISTS execution_cwd; diff --git a/server/migrations/070_issue_execution_cwd.up.sql b/server/migrations/070_issue_execution_cwd.up.sql new file mode 100644 index 0000000000..97f84d2718 --- /dev/null +++ b/server/migrations/070_issue_execution_cwd.up.sql @@ -0,0 +1,5 @@ +-- Per-issue execution working directory override (absolute path). +-- When set, daemon task execution uses this path as cwd instead of +-- creating/reusing the default isolated workdir. +ALTER TABLE issue +ADD COLUMN execution_cwd TEXT; diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 66e24649a2..27edad0e76 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -133,10 +133,12 @@ const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, number, project_id + parent_issue_id, position, due_date, number, project_id, + execution_cwd ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd ` type CreateIssueParams struct { @@ -154,6 +156,7 @@ type CreateIssueParams struct { DueDate pgtype.Timestamptz `json:"due_date"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -172,6 +175,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.DueDate, arg.Number, arg.ProjectID, + arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -197,6 +201,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } @@ -206,11 +211,12 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, due_date, number, project_id, - origin_type, origin_id + origin_type, origin_id, execution_cwd ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at + $15, $16, + $17 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd ` type CreateIssueWithOriginParams struct { @@ -230,6 +236,7 @@ type CreateIssueWithOriginParams struct { ProjectID pgtype.UUID `json:"project_id"` OriginType pgtype.Text `json:"origin_type"` OriginID pgtype.UUID `json:"origin_id"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWithOriginParams) (Issue, error) { @@ -250,6 +257,7 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith arg.ProjectID, arg.OriginType, arg.OriginID, + arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -275,6 +283,7 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } @@ -289,7 +298,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error { } const getIssue = `-- name: GetIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue WHERE id = $1 ` @@ -319,12 +328,13 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue WHERE workspace_id = $1 AND number = $2 ` @@ -359,12 +369,13 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } const getIssueByOrigin = `-- name: GetIssueByOrigin :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue WHERE workspace_id = $1 AND origin_type = $2 AND origin_id = $3 @@ -408,12 +419,13 @@ func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginPara &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue WHERE id = $1 AND workspace_id = $2 ` @@ -448,12 +460,13 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } const listChildIssues = `-- name: ListChildIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue WHERE parent_issue_id = $1 ORDER BY position ASC, created_at DESC ` @@ -490,6 +503,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ); err != nil { return nil, err } @@ -504,7 +518,8 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id + parent_issue_id, position, due_date, created_at, updated_at, number, project_id, + execution_cwd FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) @@ -547,6 +562,7 @@ type ListIssuesRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListIssuesRow, error) { @@ -586,6 +602,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI &i.UpdatedAt, &i.Number, &i.ProjectID, + &i.ExecutionCwd, ); err != nil { return nil, err } @@ -600,7 +617,8 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI const listOpenIssues = `-- name: ListOpenIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id + parent_issue_id, position, due_date, created_at, updated_at, number, project_id, + execution_cwd FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled', 'archive') @@ -639,6 +657,7 @@ type ListOpenIssuesRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { @@ -675,6 +694,7 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) &i.UpdatedAt, &i.Number, &i.ProjectID, + &i.ExecutionCwd, ); err != nil { return nil, err } @@ -732,9 +752,10 @@ UPDATE issue SET due_date = $9, parent_issue_id = $10, project_id = $11, + execution_cwd = $12, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd ` type UpdateIssueParams struct { @@ -749,6 +770,7 @@ type UpdateIssueParams struct { DueDate pgtype.Timestamptz `json:"due_date"` ParentIssueID pgtype.UUID `json:"parent_issue_id"` ProjectID pgtype.UUID `json:"project_id"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) { @@ -764,6 +786,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue arg.DueDate, arg.ParentIssueID, arg.ProjectID, + arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -789,6 +812,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } @@ -798,7 +822,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd ` type UpdateIssueStatusParams struct { @@ -832,6 +856,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.ExecutionCwd, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index b56df39ce2..9d042d6ba5 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -273,6 +273,7 @@ type Issue struct { OriginType pgtype.Text `json:"origin_type"` OriginID pgtype.UUID `json:"origin_id"` FirstExecutedAt pgtype.Timestamptz `json:"first_executed_at"` + ExecutionCwd pgtype.Text `json:"execution_cwd"` } type IssueDependency struct { diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 93acfb9acb..72a35c5fe3 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -1,7 +1,8 @@ -- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id + parent_issue_id, position, due_date, created_at, updated_at, number, project_id, + execution_cwd FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) @@ -25,9 +26,11 @@ WHERE id = $1 AND workspace_id = $2; INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, number, project_id + parent_issue_id, position, due_date, number, project_id, + execution_cwd ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + sqlc.narg('execution_cwd') ) RETURNING *; -- name: GetIssueByNumber :one @@ -46,6 +49,7 @@ UPDATE issue SET due_date = sqlc.narg('due_date'), parent_issue_id = sqlc.narg('parent_issue_id'), project_id = sqlc.narg('project_id'), + execution_cwd = sqlc.narg('execution_cwd'), updated_at = now() WHERE id = $1 RETURNING *; @@ -62,10 +66,11 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, due_date, number, project_id, - origin_type, origin_id + origin_type, origin_id, execution_cwd ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - sqlc.narg('origin_type'), sqlc.narg('origin_id') + sqlc.narg('origin_type'), sqlc.narg('origin_id'), + sqlc.narg('execution_cwd') ) RETURNING *; -- name: DeleteIssue :exec @@ -74,7 +79,8 @@ DELETE FROM issue WHERE id = $1; -- name: ListOpenIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id + parent_issue_id, position, due_date, created_at, updated_at, number, project_id, + execution_cwd FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled', 'archive') From 66b69d7d167da534f2d3b5698da1f9795c7f9de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Sat, 9 May 2026 09:04:49 +0800 Subject: [PATCH 02/18] Revert "feat: add configurable agent execution working directory" This reverts commit 975f358b7ad3fcc9a131bd1b551185944a7a89db. --- packages/core/types/api.ts | 2 - packages/core/types/issue.ts | 1 - packages/views/locales/en/modals.json | 3 - packages/views/locales/zh-Hans/modals.json | 3 - packages/views/modals/create-issue.tsx | 34 ------------ server/internal/daemon/daemon.go | 33 ++++------- server/internal/daemon/execenv/execenv.go | 23 -------- server/internal/daemon/types.go | 1 - server/internal/handler/agent.go | 1 - server/internal/handler/daemon.go | 3 - server/internal/handler/issue.go | 24 -------- .../070_issue_execution_cwd.down.sql | 2 - .../migrations/070_issue_execution_cwd.up.sql | 5 -- server/pkg/db/generated/issue.sql.go | 55 +++++-------------- server/pkg/db/generated/models.go | 1 - server/pkg/db/queries/issue.sql | 18 ++---- 16 files changed, 33 insertions(+), 176 deletions(-) delete mode 100644 server/migrations/070_issue_execution_cwd.down.sql delete mode 100644 server/migrations/070_issue_execution_cwd.up.sql diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 6bbbd3cf9d..3b94696329 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -14,7 +14,6 @@ export interface CreateIssueRequest { project_id?: string; due_date?: string; attachment_ids?: string[]; - execution_cwd?: string; } export interface UpdateIssueRequest { @@ -28,7 +27,6 @@ export interface UpdateIssueRequest { due_date?: string | null; parent_issue_id?: string | null; project_id?: string | null; - execution_cwd?: string | null; } export interface ListIssuesParams { diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index f597f98cac..78b43b9cd9 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -40,7 +40,6 @@ export interface Issue { project_id: string | null; position: number; due_date: string | null; - execution_cwd?: string | null; reactions?: IssueReaction[]; labels?: Label[]; created_at: string; diff --git a/packages/views/locales/en/modals.json b/packages/views/locales/en/modals.json index 4a4d13e0de..cf002eb950 100644 --- a/packages/views/locales/en/modals.json +++ b/packages/views/locales/en/modals.json @@ -113,9 +113,6 @@ "title": "Add sub-issue", "description": "Search for an issue to add as a sub-issue of the new issue" }, - "execution_cwd_label": "Working directory", - "execution_cwd_placeholder": "Default: isolated workspace (absolute path)", - "browse": "Browse", "agent": { "created_by": "Created by", "select_agent_aria": "Select agent", diff --git a/packages/views/locales/zh-Hans/modals.json b/packages/views/locales/zh-Hans/modals.json index 9df882a0da..92cd723b15 100644 --- a/packages/views/locales/zh-Hans/modals.json +++ b/packages/views/locales/zh-Hans/modals.json @@ -113,9 +113,6 @@ "title": "添加子 issue", "description": "搜索一个 issue 设为子级" }, - "execution_cwd_label": "工作目录", - "execution_cwd_placeholder": "默认:隔离工作空间(绝对路径)", - "browse": "浏览", "agent": { "created_by": "创建者", "select_agent_aria": "选择智能体", diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index ed084952c5..4ab2d9dafc 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -114,7 +114,6 @@ export function ManualCreatePanel({ // object, and we never need to hydrate from an ID the way we do for parent. const [childIssues, setChildIssues] = useState([]); const [childPickerOpen, setChildPickerOpen] = useState(false); - const [executionCwd, setExecutionCwd] = useState(""); // Fetch parent issue details for the chip (status/identifier/title). // List cache usually has it already, so this resolves synchronously. const wsId = useWorkspaceId(); @@ -156,7 +155,6 @@ export function ManualCreatePanel({ setParentIssueId(undefined); setChildIssues([]); setAttachmentIds([]); - setExecutionCwd(""); setDraft({ title: "", description: "", @@ -185,7 +183,6 @@ export function ManualCreatePanel({ attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, parent_issue_id: parentIssueId, project_id: projectId, - execution_cwd: executionCwd || undefined, }); // Link queued children to the new parent. Deferred to after create @@ -540,37 +537,6 @@ export function ManualCreatePanel({ }} /> - {/* Execution working directory override */} -
-
- - {t(($) => $.create_issue.execution_cwd_label)} - - setExecutionCwd(e.target.value)} - placeholder={t(($) => $.create_issue.execution_cwd_placeholder)} - className="flex-1 min-w-0 h-7 px-2 text-xs rounded-sm border bg-background text-foreground placeholder:text-muted-foreground/50" - /> - {typeof window !== "undefined" && "daemonAPI" in window && (window as any).daemonAPI?.pickDirectory ? ( - - ) : null} -
-
- {/* Footer */}
diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index eb713a7ee2..643548fbb5 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1644,26 +1644,21 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i // can't reclaim artifacts inside them mid-execution. We mark both the // predicted root for a fresh Prepare and the prior root for Reuse — they // usually differ (Reuse keeps the original task's directory). - // When ExecutionCwd is set, there is no isolated env to protect — skip GC - // marking entirely. - if task.ExecutionCwd == "" { - predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID) - d.markActiveEnvRoot(predictedRoot) - defer d.unmarkActiveEnvRoot(predictedRoot) - if task.PriorWorkDir != "" { - priorRoot := filepath.Dir(task.PriorWorkDir) - if priorRoot != predictedRoot { - d.markActiveEnvRoot(priorRoot) - defer d.unmarkActiveEnvRoot(priorRoot) - } + predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID) + d.markActiveEnvRoot(predictedRoot) + defer d.unmarkActiveEnvRoot(predictedRoot) + if task.PriorWorkDir != "" { + priorRoot := filepath.Dir(task.PriorWorkDir) + if priorRoot != predictedRoot { + d.markActiveEnvRoot(priorRoot) + defer d.unmarkActiveEnvRoot(priorRoot) } } // Try to reuse the workdir from a previous task on the same (agent, issue) pair. - // When ExecutionCwd is set, skip reuse — the user-specified directory is authoritative. var env *execenv.Environment codexVersion := d.agentVersion("codex") - if task.ExecutionCwd == "" && task.PriorWorkDir != "" { + if task.PriorWorkDir != "" { env = execenv.Reuse(task.PriorWorkDir, provider, codexVersion, taskCtx, d.logger) } if env == nil { @@ -1675,7 +1670,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i AgentName: agentName, Provider: provider, CodexVersion: codexVersion, - ExecutionCWD: task.ExecutionCwd, Task: taskCtx, }, d.logger) if err != nil { @@ -1684,17 +1678,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i } // Belt-and-suspenders: also mark whatever root we ended up with, in case // future changes diverge from PredictRootDir. - if env.RootDir != "" && task.ExecutionCwd == "" { + if env.RootDir != predictedRoot && env.RootDir != "" { d.markActiveEnvRoot(env.RootDir) defer d.unmarkActiveEnvRoot(env.RootDir) } // Inject runtime-specific config (meta skill) so the agent discovers .agent_context/. - // Skip when using a user-specified cwd to avoid overwriting existing CLAUDE.md / AGENTS.md. - if task.ExecutionCwd == "" { - if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil { - d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err) - } + if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil { + d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err) } // NOTE: No cleanup — workdir is preserved for reuse by future tasks on // the same (agent, issue) pair. The work_dir path is stored in DB on diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index a4c3e6120b..4cf8364c96 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -37,7 +37,6 @@ type PrepareParams struct { AgentName string // for git branch naming only Provider string // agent provider (determines runtime config and skill injection paths) CodexVersion string // detected Codex CLI version (only used when Provider == "codex") - ExecutionCWD string // absolute path override — when set, skip isolated env creation and use this as WorkDir Task TaskContextForEnv // context data for writing files } @@ -101,29 +100,7 @@ func PredictRootDir(workspacesRoot, workspaceID, taskID string) string { // Prepare creates an isolated execution environment for a task. // The workdir starts empty (no repo checkouts). The agent checks out repos // on demand via `multica repo checkout `. -// -// When params.ExecutionCWD is set, directory creation is skipped and the -// specified path is used as WorkDir directly. Context files are still written -// so the agent has issue metadata. func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { - // ExecutionCWD override: use the user-specified directory directly. - if params.ExecutionCWD != "" { - env := &Environment{ - RootDir: "", - WorkDir: params.ExecutionCWD, - logger: logger, - } - - // Write context files into the specified directory so the agent - // can discover the issue, skills, and project resources. - if err := writeContextFiles(params.ExecutionCWD, params.Provider, params.Task); err != nil { - return nil, fmt.Errorf("execenv: write context files to %s: %w", params.ExecutionCWD, err) - } - - logger.Info("execenv: using override cwd", "workdir", params.ExecutionCWD, "repos_available", len(params.Task.Repos)) - return env, nil - } - if params.WorkspacesRoot == "" { return nil, fmt.Errorf("execenv: workspaces root is required") } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index adb6044976..12b9160e40 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -58,7 +58,6 @@ type Task struct { AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks - ExecutionCwd string `json:"execution_cwd,omitempty"` // per-issue working directory override } // AgentData holds agent details returned by the claim endpoint. diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index a1181d8da4..dfc98e00c3 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -172,7 +172,6 @@ type AgentTaskResponse struct { AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs - ExecutionCwd string `json:"execution_cwd,omitempty"` // per-issue working directory override — when set, daemon uses this path as cwd instead of creating an isolated workdir QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue } diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 2e72b1e1a3..d6903eb1a8 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1015,9 +1015,6 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { if task.IssueID.Valid { if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil { resp.WorkspaceID = uuidToString(issue.WorkspaceID) - if issue.ExecutionCwd.Valid { - resp.ExecutionCwd = issue.ExecutionCwd.String - } var projectRepos []RepoData if issue.ProjectID.Valid { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 21a8071ff9..6db2f1b1d1 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -45,7 +45,6 @@ type IssueResponse struct { UpdatedAt string `json:"updated_at"` Reactions []IssueReactionResponse `json:"reactions,omitempty"` Attachments []AttachmentResponse `json:"attachments,omitempty"` - ExecutionCwd *string `json:"execution_cwd,omitempty"` // Labels are bulk-attached by list/detail endpoints so the client can render // chips without an N+1 round-trip per row. Pointer + omitempty so paths that // don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated @@ -76,7 +75,6 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), - ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -106,7 +104,6 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), - ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -162,7 +159,6 @@ func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueRes DueDate: timestampToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), UpdatedAt: timestampToString(i.UpdatedAt), - ExecutionCwd: textToPtr(i.ExecutionCwd), } } @@ -1062,10 +1058,6 @@ type CreateIssueRequest struct { ProjectID *string `json:"project_id"` DueDate *string `json:"due_date"` AttachmentIDs []string `json:"attachment_ids,omitempty"` - // ExecutionCwd overrides the default isolated workdir. When set, the daemon - // uses this absolute path as the agent's working directory instead of - // creating a new isolated environment. - ExecutionCwd *string `json:"execution_cwd,omitempty"` // OriginType / OriginID stamp the new issue with its provenance so // platform-internal flows can deterministically locate it later. Only // trusted callers should set these — currently the daemon CLI passes @@ -1170,11 +1162,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { dueDate = pgtype.Timestamptz{Time: t, Valid: true} } - var executionCwd pgtype.Text - if req.ExecutionCwd != nil && *req.ExecutionCwd != "" { - executionCwd = pgtype.Text{String: *req.ExecutionCwd, Valid: true} - } - // Use a transaction to atomically increment the workspace issue counter // and create the issue with the assigned number. tx, err := h.TxStarter.Begin(r.Context()) @@ -1240,7 +1227,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { ProjectID: projectID, OriginType: originType, OriginID: originID, - ExecutionCwd: executionCwd, }) } else { issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{ @@ -1258,7 +1244,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { DueDate: dueDate, Number: issueNumber, ProjectID: projectID, - ExecutionCwd: executionCwd, }) } if err != nil { @@ -1318,7 +1303,6 @@ type UpdateIssueRequest struct { DueDate *string `json:"due_date"` ParentIssueID *string `json:"parent_issue_id"` ProjectID *string `json:"project_id"` - ExecutionCwd *string `json:"execution_cwd"` } func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { @@ -1355,7 +1339,6 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { DueDate: prevIssue.DueDate, ParentIssueID: prevIssue.ParentIssueID, ProjectID: prevIssue.ProjectID, - ExecutionCwd: prevIssue.ExecutionCwd, } // COALESCE fields — only set when explicitly provided @@ -1405,13 +1388,6 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date } } - if _, ok := rawFields["execution_cwd"]; ok { - if req.ExecutionCwd != nil && *req.ExecutionCwd != "" { - params.ExecutionCwd = pgtype.Text{String: *req.ExecutionCwd, Valid: true} - } else { - params.ExecutionCwd = pgtype.Text{Valid: false} // explicit null = clear override - } - } if _, ok := rawFields["parent_issue_id"]; ok { if req.ParentIssueID != nil { newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id") diff --git a/server/migrations/070_issue_execution_cwd.down.sql b/server/migrations/070_issue_execution_cwd.down.sql deleted file mode 100644 index be4b959a9e..0000000000 --- a/server/migrations/070_issue_execution_cwd.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE issue -DROP COLUMN IF EXISTS execution_cwd; diff --git a/server/migrations/070_issue_execution_cwd.up.sql b/server/migrations/070_issue_execution_cwd.up.sql deleted file mode 100644 index 97f84d2718..0000000000 --- a/server/migrations/070_issue_execution_cwd.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Per-issue execution working directory override (absolute path). --- When set, daemon task execution uses this path as cwd instead of --- creating/reusing the default isolated workdir. -ALTER TABLE issue -ADD COLUMN execution_cwd TEXT; diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 27edad0e76..66e24649a2 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -133,12 +133,10 @@ const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, number, project_id, - execution_cwd + parent_issue_id, position, due_date, number, project_id ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at ` type CreateIssueParams struct { @@ -156,7 +154,6 @@ type CreateIssueParams struct { DueDate pgtype.Timestamptz `json:"due_date"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -175,7 +172,6 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.DueDate, arg.Number, arg.ProjectID, - arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -201,7 +197,6 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } @@ -211,12 +206,11 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, due_date, number, project_id, - origin_type, origin_id, execution_cwd + origin_type, origin_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, - $17 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd + $15, $16 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at ` type CreateIssueWithOriginParams struct { @@ -236,7 +230,6 @@ type CreateIssueWithOriginParams struct { ProjectID pgtype.UUID `json:"project_id"` OriginType pgtype.Text `json:"origin_type"` OriginID pgtype.UUID `json:"origin_id"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWithOriginParams) (Issue, error) { @@ -257,7 +250,6 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith arg.ProjectID, arg.OriginType, arg.OriginID, - arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -283,7 +275,6 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } @@ -298,7 +289,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error { } const getIssue = `-- name: GetIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue WHERE id = $1 ` @@ -328,13 +319,12 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue WHERE workspace_id = $1 AND number = $2 ` @@ -369,13 +359,12 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } const getIssueByOrigin = `-- name: GetIssueByOrigin :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue WHERE workspace_id = $1 AND origin_type = $2 AND origin_id = $3 @@ -419,13 +408,12 @@ func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginPara &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue WHERE id = $1 AND workspace_id = $2 ` @@ -460,13 +448,12 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } const listChildIssues = `-- name: ListChildIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue WHERE parent_issue_id = $1 ORDER BY position ASC, created_at DESC ` @@ -503,7 +490,6 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ); err != nil { return nil, err } @@ -518,8 +504,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id, - execution_cwd + parent_issue_id, position, due_date, created_at, updated_at, number, project_id FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) @@ -562,7 +547,6 @@ type ListIssuesRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListIssuesRow, error) { @@ -602,7 +586,6 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI &i.UpdatedAt, &i.Number, &i.ProjectID, - &i.ExecutionCwd, ); err != nil { return nil, err } @@ -617,8 +600,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI const listOpenIssues = `-- name: ListOpenIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id, - execution_cwd + parent_issue_id, position, due_date, created_at, updated_at, number, project_id FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled', 'archive') @@ -657,7 +639,6 @@ type ListOpenIssuesRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { @@ -694,7 +675,6 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) &i.UpdatedAt, &i.Number, &i.ProjectID, - &i.ExecutionCwd, ); err != nil { return nil, err } @@ -752,10 +732,9 @@ UPDATE issue SET due_date = $9, parent_issue_id = $10, project_id = $11, - execution_cwd = $12, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at ` type UpdateIssueParams struct { @@ -770,7 +749,6 @@ type UpdateIssueParams struct { DueDate pgtype.Timestamptz `json:"due_date"` ParentIssueID pgtype.UUID `json:"parent_issue_id"` ProjectID pgtype.UUID `json:"project_id"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) { @@ -786,7 +764,6 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue arg.DueDate, arg.ParentIssueID, arg.ProjectID, - arg.ExecutionCwd, ) var i Issue err := row.Scan( @@ -812,7 +789,6 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } @@ -822,7 +798,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, execution_cwd +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at ` type UpdateIssueStatusParams struct { @@ -856,7 +832,6 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.OriginType, &i.OriginID, &i.FirstExecutedAt, - &i.ExecutionCwd, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index ef0604a646..3adc66634d 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -273,7 +273,6 @@ type Issue struct { OriginType pgtype.Text `json:"origin_type"` OriginID pgtype.UUID `json:"origin_id"` FirstExecutedAt pgtype.Timestamptz `json:"first_executed_at"` - ExecutionCwd pgtype.Text `json:"execution_cwd"` } type IssueDependency struct { diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 72a35c5fe3..93acfb9acb 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -1,8 +1,7 @@ -- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id, - execution_cwd + parent_issue_id, position, due_date, created_at, updated_at, number, project_id FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) @@ -26,11 +25,9 @@ WHERE id = $1 AND workspace_id = $2; INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, number, project_id, - execution_cwd + parent_issue_id, position, due_date, number, project_id ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - sqlc.narg('execution_cwd') + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) RETURNING *; -- name: GetIssueByNumber :one @@ -49,7 +46,6 @@ UPDATE issue SET due_date = sqlc.narg('due_date'), parent_issue_id = sqlc.narg('parent_issue_id'), project_id = sqlc.narg('project_id'), - execution_cwd = sqlc.narg('execution_cwd'), updated_at = now() WHERE id = $1 RETURNING *; @@ -66,11 +62,10 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, due_date, number, project_id, - origin_type, origin_id, execution_cwd + origin_type, origin_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - sqlc.narg('origin_type'), sqlc.narg('origin_id'), - sqlc.narg('execution_cwd') + sqlc.narg('origin_type'), sqlc.narg('origin_id') ) RETURNING *; -- name: DeleteIssue :exec @@ -79,8 +74,7 @@ DELETE FROM issue WHERE id = $1; -- name: ListOpenIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date, created_at, updated_at, number, project_id, - execution_cwd + parent_issue_id, position, due_date, created_at, updated_at, number, project_id FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled', 'archive') From 3493ecf14faaffb6050996c79eec744b0d9e5f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Sat, 9 May 2026 09:06:12 +0800 Subject: [PATCH 03/18] feat: detailed agent operation tracking with diff and command output Enhance agent transcript dialog to show command execution results in a terminal-styled viewer and file diffs with color-coded +/- lines. Skip output truncation for command tools to preserve full results. --- packages/core/types/events.ts | 1 + .../agent-transcript-dialog.tsx | 29 +++- .../common/task-transcript/build-timeline.ts | 19 +++ .../common/task-transcript/command-output.tsx | 73 ++++++++++ .../common/task-transcript/diff-viewer.tsx | 134 ++++++++++++++++++ server/internal/daemon/client.go | 1 + server/internal/daemon/daemon.go | 6 +- server/internal/handler/daemon.go | 2 + server/pkg/agent/agent.go | 19 +-- server/pkg/protocol/messages.go | 1 + 10 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 packages/views/common/task-transcript/command-output.tsx create mode 100644 packages/views/common/task-transcript/diff-viewer.tsx diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index db6554bf5d..7c2b5a7bdb 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -195,6 +195,7 @@ export interface TaskMessagePayload { content?: string; input?: Record; output?: string; + meta?: Record; } export interface TaskQueuedPayload { diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index a315f7ae57..1c563b4fa7 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -34,6 +34,9 @@ import { api } from "@multica/core/api"; import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; import type { TimelineItem } from "./build-timeline"; +import { isCommandTool, isEditTool } from "./build-timeline"; +import { DiffViewer } from "./diff-viewer"; +import { CommandOutput } from "./command-output"; import { useT } from "../../i18n"; interface AgentTranscriptDialogProps { @@ -664,13 +667,34 @@ const TranscriptEventRow = ({ function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { - case "tool_use": + case "tool_use": { + if (isEditTool(item.tool) && item.input) { + const oldText = typeof item.input.old_text === "string" ? item.input.old_text : undefined; + const newText = typeof item.input.new_text === "string" ? item.input.new_text : undefined; + if (oldText || newText) { + return ; + } + } return (
           {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
         
); - case "tool_result": + } + case "tool_result": { + if (isCommandTool(item.tool)) { + return ; + } + if (isEditTool(item.tool)) { + const metaDiff = item.meta?.diff as { old_text?: string; new_text?: string } | undefined; + return ( + + ); + } return (
           {item.output
@@ -680,6 +704,7 @@ function EventDetailContent({ item }: { item: TimelineItem }) {
             : ""}
         
); + } case "thinking": return (
diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts
index d0d97ba4f1..96f46629c2 100644
--- a/packages/views/common/task-transcript/build-timeline.ts
+++ b/packages/views/common/task-transcript/build-timeline.ts
@@ -9,6 +9,24 @@ export interface TimelineItem {
   content?: string;
   input?: Record;
   output?: string;
+  meta?: Record;
+}
+
+/** Tool names that execute shell commands. */
+const COMMAND_TOOLS = new Set(["Bash", "exec_command", "terminal", "Run command"]);
+
+/** Tool names that modify files (edits, writes, patches). */
+const EDIT_TOOLS = new Set([
+  "Edit", "Write", "patch_apply", "edit_file", "write_file", "patch",
+  "multi_edit", "multiedit",
+]);
+
+export function isCommandTool(tool?: string): boolean {
+  return tool != null && COMMAND_TOOLS.has(tool);
+}
+
+export function isEditTool(tool?: string): boolean {
+  return tool != null && EDIT_TOOLS.has(tool);
 }
 
 /** Build a chronologically ordered timeline from raw task messages. */
@@ -22,6 +40,7 @@ export function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
       content: msg.content ? redactSecrets(msg.content) : msg.content,
       input: msg.input,
       output: msg.output ? redactSecrets(msg.output) : msg.output,
+      meta: msg.meta,
     });
   }
   return items.sort((a, b) => a.seq - b.seq);
diff --git a/packages/views/common/task-transcript/command-output.tsx b/packages/views/common/task-transcript/command-output.tsx
new file mode 100644
index 0000000000..1902c0b3da
--- /dev/null
+++ b/packages/views/common/task-transcript/command-output.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronDown, ChevronUp, Copy, Check, Terminal } from "lucide-react";
+
+interface CommandOutputProps {
+  output?: string;
+}
+
+export function CommandOutput({ output }: CommandOutputProps) {
+  const [expanded, setExpanded] = useState(false);
+  const [copied, setCopied] = useState(false);
+
+  const text = output ?? "";
+  const lines = text.split("\n");
+  const isLong = lines.length > 50;
+  const displayLines = expanded || !isLong ? lines : lines.slice(0, 50);
+  const truncated = isLong && !expanded;
+
+  const handleCopy = async () => {
+    await navigator.clipboard.writeText(text);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  return (
+    
+
+
+ + Command output + {isLong && ( + + ({lines.length} lines) + + )} +
+ +
+
+        {displayLines.map((line, i) => (
+          
{line}
+ ))} + {truncated && ( + + )} + {expanded && isLong && ( + + )} +
+
+ ); +} diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx new file mode 100644 index 0000000000..5c14de5e65 --- /dev/null +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react"; + +interface DiffViewerProps { + /** Raw output text that may contain a unified diff */ + output?: string; + /** Structured diff data (old/new text) from ACP-style backends */ + oldText?: string; + newText?: string; +} + +interface DiffLine { + type: "add" | "del" | "context" | "header"; + text: string; +} + +function parseUnifiedDiff(text: string): DiffLine[] { + const lines: DiffLine[] = []; + for (const line of text.split("\n")) { + if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("@@")) { + lines.push({ type: "header", text: line }); + } else if (line.startsWith("+")) { + lines.push({ type: "add", text: line }); + } else if (line.startsWith("-")) { + lines.push({ type: "del", text: line }); + } else { + lines.push({ type: "context", text: line }); + } + } + return lines; +} + +function generateStructuredDiff(oldText: string, newText: string): DiffLine[] { + const lines: DiffLine[] = []; + lines.push({ type: "header", text: "--- a/file" }); + lines.push({ type: "header", text: "+++ b/file" }); + + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + for (const l of oldLines) { + lines.push({ type: "del", text: `-${l}` }); + } + for (const l of newLines) { + lines.push({ type: "add", text: `+${l}` }); + } + + return lines; +} + +export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + let hunkLines: DiffLine[]; + if (oldText != null && newText != null) { + hunkLines = generateStructuredDiff(oldText, newText); + } else if (output) { + hunkLines = parseUnifiedDiff(output); + } else { + hunkLines = []; + } + + const hasDiffMarkers = hunkLines.some( + (l) => l.type === "add" || l.type === "del", + ); + const isLong = hunkLines.length > 100; + const displayLines = expanded || !isLong ? hunkLines : hunkLines.slice(0, 100); + const truncated = !expanded && isLong; + + const handleCopy = async () => { + const text = output ?? `${oldText ?? ""}\n→\n${newText ?? ""}`; + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + {hasDiffMarkers ? "File changes" : "File content"} + + +
+
+        {displayLines.map((line, i) => (
+          
+ {line.text} +
+ ))} + {truncated && ( + + )} + {expanded && hunkLines.length > 100 && ( + + )} +
+
+ ); +} diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index f25cf4c90f..e0769657b2 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -152,6 +152,7 @@ type TaskMessageData struct { Content string `json:"content,omitempty"` Input map[string]any `json:"input,omitempty"` Output string `json:"output,omitempty"` + Meta map[string]any `json:"meta,omitempty"` } func (c *Client) ReportTaskMessages(ctx context.Context, taskID string, messages []TaskMessageData) error { diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 643548fbb5..2c72d798d4 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -2077,8 +2077,9 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro case agent.MessageToolResult: s := seq.Add(1) output := msg.Output - if len(output) > 8192 { - output = output[:8192] + isCommand := msg.Tool == "Bash" || msg.Tool == "exec_command" || msg.Tool == "terminal" + if !isCommand && len(output) > 32768 { + output = output[:32768] } toolName := msg.Tool if toolName == "" && msg.CallID != "" { @@ -2093,6 +2094,7 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro Type: "tool_result", Tool: toolName, Output: output, + Meta: msg.ToolResultMeta, }) mu.Unlock() case agent.MessageThinking: diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d6903eb1a8..ddb11c6cff 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1497,6 +1497,7 @@ type TaskMessageRequest struct { Content string `json:"content,omitempty"` Input map[string]any `json:"input,omitempty"` Output string `json:"output,omitempty"` + Meta map[string]any `json:"meta,omitempty"` } type TaskMessageBatchRequest struct { @@ -1565,6 +1566,7 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { Content: msg.Content, Input: msg.Input, Output: msg.Output, + Meta: msg.Meta, }) } } diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 3704a5235f..5db17e5f2a 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -58,15 +58,16 @@ const ( // Message is a unified event emitted by an agent during execution. type Message struct { - Type MessageType - Content string // text content (Text, Error, Log) - Tool string // tool name (ToolUse, ToolResult) - CallID string // tool call ID (ToolUse, ToolResult) - Input map[string]any // tool input (ToolUse) - Output string // tool output (ToolResult) - Status string // agent status string (Status) - Level string // log level (Log) - SessionID string // backend session id (Status), for early resume-pointer pinning + Type MessageType + Content string // text content (Text, Error, Log) + Tool string // tool name (ToolUse, ToolResult) + CallID string // tool call ID (ToolUse, ToolResult) + Input map[string]any // tool input (ToolUse) + Output string // tool output (ToolResult) + ToolResultMeta map[string]any // structured metadata (diff hunks, exit codes, etc.) attached to tool results + Status string // agent status string (Status) + Level string // log level (Log) + SessionID string // backend session id (Status), for early resume-pointer pinning } // TokenUsage tracks token consumption for a single model. diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 0014243d75..662dfab27c 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -48,6 +48,7 @@ type TaskMessagePayload struct { Content string `json:"content,omitempty"` // text content Input map[string]any `json:"input,omitempty"` // tool input (tool_use only) Output string `json:"output,omitempty"` // tool output (tool_result only) + Meta map[string]any `json:"meta,omitempty"` // structured metadata (diff hunks, exit codes, etc.) } // DaemonRegisterPayload is sent from daemon to server on connection. From 58e4dc7a48013f147128ecb49412b67c8844e75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Sat, 9 May 2026 09:15:32 +0800 Subject: [PATCH 04/18] fix: add i18n keys for command output and diff viewer components Replace hardcoded strings with translation keys to satisfy the i18next/no-literal-string ESLint rule. --- .../views/common/task-transcript/command-output.tsx | 10 ++++++---- packages/views/common/task-transcript/diff-viewer.tsx | 8 +++++--- packages/views/locales/en/agents.json | 9 ++++++++- packages/views/locales/zh-Hans/agents.json | 8 +++++++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/views/common/task-transcript/command-output.tsx b/packages/views/common/task-transcript/command-output.tsx index 1902c0b3da..1688b15fb4 100644 --- a/packages/views/common/task-transcript/command-output.tsx +++ b/packages/views/common/task-transcript/command-output.tsx @@ -2,12 +2,14 @@ import { useState } from "react"; import { ChevronDown, ChevronUp, Copy, Check, Terminal } from "lucide-react"; +import { useT } from "../../i18n"; interface CommandOutputProps { output?: string; } export function CommandOutput({ output }: CommandOutputProps) { + const { t } = useT("agents"); const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); @@ -28,10 +30,10 @@ export function CommandOutput({ output }: CommandOutputProps) {
- Command output + {t(($) => $.transcript.command_output)} {isLong && ( - ({lines.length} lines) + ({t(($) => $.transcript.lines_count, { count: lines.length })}) )}
@@ -54,7 +56,7 @@ export function CommandOutput({ output }: CommandOutputProps) { onClick={() => setExpanded(true)} > - Show all {lines.length} lines + {t(($) => $.transcript.show_all_lines, { count: lines.length })} )} {expanded && isLong && ( @@ -64,7 +66,7 @@ export function CommandOutput({ output }: CommandOutputProps) { onClick={() => setExpanded(false)} > - Collapse + {t(($) => $.transcript.collapse)} )}
diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx index 5c14de5e65..40355da3f3 100644 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react"; +import { useT } from "../../i18n"; interface DiffViewerProps { /** Raw output text that may contain a unified diff */ @@ -51,6 +52,7 @@ function generateStructuredDiff(oldText: string, newText: string): DiffLine[] { } export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { + const { t } = useT("agents"); const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); @@ -81,7 +83,7 @@ export function DiffViewer({ output, oldText, newText }: DiffViewerProps) {
- {hasDiffMarkers ? "File changes" : "File content"} + {hasDiffMarkers ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)} )} {expanded && hunkLines.length > 100 && ( @@ -125,7 +127,7 @@ export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { onClick={() => setExpanded(false)} > - Collapse + {t(($) => $.transcript.collapse)} )} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 327755cad5..5ff3ef6d5e 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -347,7 +347,14 @@ "copy_filtered": "Copy filtered", "copied": "Copied", "waiting_events": "Waiting for events...", - "no_data": "No execution data recorded." + "no_data": "No execution data recorded.", + "command_output": "Command output", + "lines_count_one": "{{count}} line", + "lines_count_other": "{{count}} lines", + "show_all_lines_other": "Show all {{count}} lines", + "collapse": "Collapse", + "file_changes": "File changes", + "file_content": "File content" }, "task_failure": { "agent_error": "Agent execution error", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 516b96450e..660d373ad0 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -339,7 +339,13 @@ "copy_filtered": "复制筛选结果", "copied": "已复制", "waiting_events": "等待事件中...", - "no_data": "未记录执行数据。" + "no_data": "未记录执行数据。", + "command_output": "命令输出", + "lines_count_other": "{{count}} 行", + "show_all_lines_other": "显示全部 {{count}} 行", + "collapse": "收起", + "file_changes": "文件变更", + "file_content": "文件内容" }, "task_failure": { "agent_error": "智能体执行出错", From c90fc63ed5aafa57ecec082abdf3a9b23105520b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Sun, 10 May 2026 10:39:07 +0800 Subject: [PATCH 05/18] fix(views): use theme-aware colors for CommandOutput and DiffViewer Replace hardcoded dark zinc colors in CommandOutput with light/dark adaptive variants. Boost diff line background opacity in dark mode for better contrast. --- .../common/task-transcript/command-output.tsx | 18 +++++++++--------- .../common/task-transcript/diff-viewer.tsx | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/views/common/task-transcript/command-output.tsx b/packages/views/common/task-transcript/command-output.tsx index 1688b15fb4..823e3d0330 100644 --- a/packages/views/common/task-transcript/command-output.tsx +++ b/packages/views/common/task-transcript/command-output.tsx @@ -26,33 +26,33 @@ export function CommandOutput({ output }: CommandOutputProps) { }; return ( -
-
+
+
- - {t(($) => $.transcript.command_output)} + + {t(($) => $.transcript.command_output)} {isLong && ( - + ({t(($) => $.transcript.lines_count, { count: lines.length })}) )}
-
+      
         {displayLines.map((line, i) => (
           
{line}
))} {truncated && (
-
+      
         {displayLines.map((line, i) => (
           
{line}
))} {truncated && ( )} {expanded && isLong && ( @@ -66,7 +64,7 @@ export function CommandOutput({ output }: CommandOutputProps) { onClick={() => setExpanded(false)} > - {t(($) => $.transcript.collapse)} + Collapse )}
diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx index 40355da3f3..5c14de5e65 100644 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react"; -import { useT } from "../../i18n"; interface DiffViewerProps { /** Raw output text that may contain a unified diff */ @@ -52,7 +51,6 @@ function generateStructuredDiff(oldText: string, newText: string): DiffLine[] { } export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { - const { t } = useT("agents"); const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); @@ -83,7 +81,7 @@ export function DiffViewer({ output, oldText, newText }: DiffViewerProps) {
- {hasDiffMarkers ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)} + {hasDiffMarkers ? "File changes" : "File content"} )} {expanded && hunkLines.length > 100 && ( @@ -127,7 +125,7 @@ export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { onClick={() => setExpanded(false)} > - {t(($) => $.transcript.collapse)} + Collapse )}
diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 5ff3ef6d5e..327755cad5 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -347,14 +347,7 @@ "copy_filtered": "Copy filtered", "copied": "Copied", "waiting_events": "Waiting for events...", - "no_data": "No execution data recorded.", - "command_output": "Command output", - "lines_count_one": "{{count}} line", - "lines_count_other": "{{count}} lines", - "show_all_lines_other": "Show all {{count}} lines", - "collapse": "Collapse", - "file_changes": "File changes", - "file_content": "File content" + "no_data": "No execution data recorded." }, "task_failure": { "agent_error": "Agent execution error", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 660d373ad0..516b96450e 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -339,13 +339,7 @@ "copy_filtered": "复制筛选结果", "copied": "已复制", "waiting_events": "等待事件中...", - "no_data": "未记录执行数据。", - "command_output": "命令输出", - "lines_count_other": "{{count}} 行", - "show_all_lines_other": "显示全部 {{count}} 行", - "collapse": "收起", - "file_changes": "文件变更", - "file_content": "文件内容" + "no_data": "未记录执行数据。" }, "task_failure": { "agent_error": "智能体执行出错", From 01d73afdeb8f462ec81b6e4d27f38c28d48642b5 Mon Sep 17 00:00:00 2001 From: WSRer <1749094641@qq.com> Date: Tue, 12 May 2026 23:35:22 +0800 Subject: [PATCH 08/18] Revert "feat: detailed agent operation tracking with diff and command output" This reverts commit 3493ecf14faaffb6050996c79eec744b0d9e5f6e. --- packages/core/types/events.ts | 1 - .../agent-transcript-dialog.tsx | 29 +--- .../common/task-transcript/build-timeline.ts | 19 --- .../common/task-transcript/command-output.tsx | 73 ---------- .../common/task-transcript/diff-viewer.tsx | 134 ------------------ server/internal/daemon/client.go | 1 - server/internal/daemon/daemon.go | 6 +- server/internal/handler/daemon.go | 2 - server/pkg/agent/agent.go | 19 ++- server/pkg/protocol/messages.go | 1 - 10 files changed, 13 insertions(+), 272 deletions(-) delete mode 100644 packages/views/common/task-transcript/command-output.tsx delete mode 100644 packages/views/common/task-transcript/diff-viewer.tsx diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 7c2b5a7bdb..db6554bf5d 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -195,7 +195,6 @@ export interface TaskMessagePayload { content?: string; input?: Record; output?: string; - meta?: Record; } export interface TaskQueuedPayload { diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 1c563b4fa7..a315f7ae57 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -34,9 +34,6 @@ import { api } from "@multica/core/api"; import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; import type { TimelineItem } from "./build-timeline"; -import { isCommandTool, isEditTool } from "./build-timeline"; -import { DiffViewer } from "./diff-viewer"; -import { CommandOutput } from "./command-output"; import { useT } from "../../i18n"; interface AgentTranscriptDialogProps { @@ -667,34 +664,13 @@ const TranscriptEventRow = ({ function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { - case "tool_use": { - if (isEditTool(item.tool) && item.input) { - const oldText = typeof item.input.old_text === "string" ? item.input.old_text : undefined; - const newText = typeof item.input.new_text === "string" ? item.input.new_text : undefined; - if (oldText || newText) { - return ; - } - } + case "tool_use": return (
           {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
         
); - } - case "tool_result": { - if (isCommandTool(item.tool)) { - return ; - } - if (isEditTool(item.tool)) { - const metaDiff = item.meta?.diff as { old_text?: string; new_text?: string } | undefined; - return ( - - ); - } + case "tool_result": return (
           {item.output
@@ -704,7 +680,6 @@ function EventDetailContent({ item }: { item: TimelineItem }) {
             : ""}
         
); - } case "thinking": return (
diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts
index 96f46629c2..d0d97ba4f1 100644
--- a/packages/views/common/task-transcript/build-timeline.ts
+++ b/packages/views/common/task-transcript/build-timeline.ts
@@ -9,24 +9,6 @@ export interface TimelineItem {
   content?: string;
   input?: Record;
   output?: string;
-  meta?: Record;
-}
-
-/** Tool names that execute shell commands. */
-const COMMAND_TOOLS = new Set(["Bash", "exec_command", "terminal", "Run command"]);
-
-/** Tool names that modify files (edits, writes, patches). */
-const EDIT_TOOLS = new Set([
-  "Edit", "Write", "patch_apply", "edit_file", "write_file", "patch",
-  "multi_edit", "multiedit",
-]);
-
-export function isCommandTool(tool?: string): boolean {
-  return tool != null && COMMAND_TOOLS.has(tool);
-}
-
-export function isEditTool(tool?: string): boolean {
-  return tool != null && EDIT_TOOLS.has(tool);
 }
 
 /** Build a chronologically ordered timeline from raw task messages. */
@@ -40,7 +22,6 @@ export function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
       content: msg.content ? redactSecrets(msg.content) : msg.content,
       input: msg.input,
       output: msg.output ? redactSecrets(msg.output) : msg.output,
-      meta: msg.meta,
     });
   }
   return items.sort((a, b) => a.seq - b.seq);
diff --git a/packages/views/common/task-transcript/command-output.tsx b/packages/views/common/task-transcript/command-output.tsx
deleted file mode 100644
index 1902c0b3da..0000000000
--- a/packages/views/common/task-transcript/command-output.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { ChevronDown, ChevronUp, Copy, Check, Terminal } from "lucide-react";
-
-interface CommandOutputProps {
-  output?: string;
-}
-
-export function CommandOutput({ output }: CommandOutputProps) {
-  const [expanded, setExpanded] = useState(false);
-  const [copied, setCopied] = useState(false);
-
-  const text = output ?? "";
-  const lines = text.split("\n");
-  const isLong = lines.length > 50;
-  const displayLines = expanded || !isLong ? lines : lines.slice(0, 50);
-  const truncated = isLong && !expanded;
-
-  const handleCopy = async () => {
-    await navigator.clipboard.writeText(text);
-    setCopied(true);
-    setTimeout(() => setCopied(false), 2000);
-  };
-
-  return (
-    
-
-
- - Command output - {isLong && ( - - ({lines.length} lines) - - )} -
- -
-
-        {displayLines.map((line, i) => (
-          
{line}
- ))} - {truncated && ( - - )} - {expanded && isLong && ( - - )} -
-
- ); -} diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx deleted file mode 100644 index 5c14de5e65..0000000000 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react"; - -interface DiffViewerProps { - /** Raw output text that may contain a unified diff */ - output?: string; - /** Structured diff data (old/new text) from ACP-style backends */ - oldText?: string; - newText?: string; -} - -interface DiffLine { - type: "add" | "del" | "context" | "header"; - text: string; -} - -function parseUnifiedDiff(text: string): DiffLine[] { - const lines: DiffLine[] = []; - for (const line of text.split("\n")) { - if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("@@")) { - lines.push({ type: "header", text: line }); - } else if (line.startsWith("+")) { - lines.push({ type: "add", text: line }); - } else if (line.startsWith("-")) { - lines.push({ type: "del", text: line }); - } else { - lines.push({ type: "context", text: line }); - } - } - return lines; -} - -function generateStructuredDiff(oldText: string, newText: string): DiffLine[] { - const lines: DiffLine[] = []; - lines.push({ type: "header", text: "--- a/file" }); - lines.push({ type: "header", text: "+++ b/file" }); - - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); - - for (const l of oldLines) { - lines.push({ type: "del", text: `-${l}` }); - } - for (const l of newLines) { - lines.push({ type: "add", text: `+${l}` }); - } - - return lines; -} - -export function DiffViewer({ output, oldText, newText }: DiffViewerProps) { - const [expanded, setExpanded] = useState(false); - const [copied, setCopied] = useState(false); - - let hunkLines: DiffLine[]; - if (oldText != null && newText != null) { - hunkLines = generateStructuredDiff(oldText, newText); - } else if (output) { - hunkLines = parseUnifiedDiff(output); - } else { - hunkLines = []; - } - - const hasDiffMarkers = hunkLines.some( - (l) => l.type === "add" || l.type === "del", - ); - const isLong = hunkLines.length > 100; - const displayLines = expanded || !isLong ? hunkLines : hunkLines.slice(0, 100); - const truncated = !expanded && isLong; - - const handleCopy = async () => { - const text = output ?? `${oldText ?? ""}\n→\n${newText ?? ""}`; - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
-
- - {hasDiffMarkers ? "File changes" : "File content"} - - -
-
-        {displayLines.map((line, i) => (
-          
- {line.text} -
- ))} - {truncated && ( - - )} - {expanded && hunkLines.length > 100 && ( - - )} -
-
- ); -} diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index e0769657b2..f25cf4c90f 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -152,7 +152,6 @@ type TaskMessageData struct { Content string `json:"content,omitempty"` Input map[string]any `json:"input,omitempty"` Output string `json:"output,omitempty"` - Meta map[string]any `json:"meta,omitempty"` } func (c *Client) ReportTaskMessages(ctx context.Context, taskID string, messages []TaskMessageData) error { diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 2c72d798d4..643548fbb5 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -2077,9 +2077,8 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro case agent.MessageToolResult: s := seq.Add(1) output := msg.Output - isCommand := msg.Tool == "Bash" || msg.Tool == "exec_command" || msg.Tool == "terminal" - if !isCommand && len(output) > 32768 { - output = output[:32768] + if len(output) > 8192 { + output = output[:8192] } toolName := msg.Tool if toolName == "" && msg.CallID != "" { @@ -2094,7 +2093,6 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro Type: "tool_result", Tool: toolName, Output: output, - Meta: msg.ToolResultMeta, }) mu.Unlock() case agent.MessageThinking: diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index ddb11c6cff..d6903eb1a8 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1497,7 +1497,6 @@ type TaskMessageRequest struct { Content string `json:"content,omitempty"` Input map[string]any `json:"input,omitempty"` Output string `json:"output,omitempty"` - Meta map[string]any `json:"meta,omitempty"` } type TaskMessageBatchRequest struct { @@ -1566,7 +1565,6 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { Content: msg.Content, Input: msg.Input, Output: msg.Output, - Meta: msg.Meta, }) } } diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 5db17e5f2a..3704a5235f 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -58,16 +58,15 @@ const ( // Message is a unified event emitted by an agent during execution. type Message struct { - Type MessageType - Content string // text content (Text, Error, Log) - Tool string // tool name (ToolUse, ToolResult) - CallID string // tool call ID (ToolUse, ToolResult) - Input map[string]any // tool input (ToolUse) - Output string // tool output (ToolResult) - ToolResultMeta map[string]any // structured metadata (diff hunks, exit codes, etc.) attached to tool results - Status string // agent status string (Status) - Level string // log level (Log) - SessionID string // backend session id (Status), for early resume-pointer pinning + Type MessageType + Content string // text content (Text, Error, Log) + Tool string // tool name (ToolUse, ToolResult) + CallID string // tool call ID (ToolUse, ToolResult) + Input map[string]any // tool input (ToolUse) + Output string // tool output (ToolResult) + Status string // agent status string (Status) + Level string // log level (Log) + SessionID string // backend session id (Status), for early resume-pointer pinning } // TokenUsage tracks token consumption for a single model. diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 662dfab27c..0014243d75 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -48,7 +48,6 @@ type TaskMessagePayload struct { Content string `json:"content,omitempty"` // text content Input map[string]any `json:"input,omitempty"` // tool input (tool_use only) Output string `json:"output,omitempty"` // tool output (tool_result only) - Meta map[string]any `json:"meta,omitempty"` // structured metadata (diff hunks, exit codes, etc.) } // DaemonRegisterPayload is sent from daemon to server on connection. From cc3595678136d915b9a91c1109c591313357526b Mon Sep 17 00:00:00 2001 From: WSRer <1749094641@qq.com> Date: Wed, 13 May 2026 00:22:12 +0800 Subject: [PATCH 09/18] feat(transcript): restore cross-agent file diff viewer with unified/split modes --- .../agent-transcript-dialog.tsx | 35 ++- .../task-transcript/build-timeline.test.ts | 38 +++ .../common/task-transcript/build-timeline.ts | 53 ++++ .../task-transcript/diff-viewer.test.tsx | 59 ++++ .../common/task-transcript/diff-viewer.tsx | 288 ++++++++++++++++++ packages/views/locales/en/agents.json | 7 + packages/views/locales/zh-Hans/agents.json | 7 + server/pkg/agent/codex.go | 58 ++++ server/pkg/agent/codex_test.go | 52 ++++ 9 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 packages/views/common/task-transcript/build-timeline.test.ts create mode 100644 packages/views/common/task-transcript/diff-viewer.test.tsx create mode 100644 packages/views/common/task-transcript/diff-viewer.tsx diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index a315f7ae57..58b9c1e84a 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -33,7 +33,8 @@ import { ActorAvatar } from "../actor-avatar"; import { api } from "@multica/core/api"; import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; -import type { TimelineItem } from "./build-timeline"; +import { isEditTool, looksLikeUnifiedDiff, type TimelineItem } from "./build-timeline"; +import { DiffViewer } from "./diff-viewer"; import { useT } from "../../i18n"; interface AgentTranscriptDialogProps { @@ -587,10 +588,25 @@ const TranscriptEventRow = ({ const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); + const toolUseHasInlineDiff = + item.type === "tool_use" && + isEditTool(item.tool) && + item.input != null && + ( + typeof item.input.old_text === "string" || + typeof item.input.new_text === "string" + ); const hasDetail = - (item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) || - (item.type === "tool_result" && item.output && item.output.length > 0) || + (item.type === "tool_use" && ( + (item.input && Object.keys(item.input).length > 0) || + toolUseHasInlineDiff + )) || + (item.type === "tool_result" && ( + (item.output && item.output.length > 0) || + isEditTool(item.tool) || + looksLikeUnifiedDiff(item.output) + )) || (item.type === "thinking" && item.content && item.content.length > 0) || (item.type === "text" && item.content && item.content.split("\n").length > 1) || (item.type === "error" && item.content && item.content.length > 0); @@ -664,13 +680,24 @@ const TranscriptEventRow = ({ function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { - case "tool_use": + case "tool_use": { + if (isEditTool(item.tool) && item.input) { + const oldText = typeof item.input.old_text === "string" ? item.input.old_text : undefined; + const newText = typeof item.input.new_text === "string" ? item.input.new_text : undefined; + if (oldText != null || newText != null) { + return ; + } + } return (
           {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
         
); + } case "tool_result": + if (isEditTool(item.tool) || looksLikeUnifiedDiff(item.output)) { + return ; + } return (
           {item.output
diff --git a/packages/views/common/task-transcript/build-timeline.test.ts b/packages/views/common/task-transcript/build-timeline.test.ts
new file mode 100644
index 0000000000..2ac6a4d57b
--- /dev/null
+++ b/packages/views/common/task-transcript/build-timeline.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { isEditTool, looksLikeUnifiedDiff } from "./build-timeline";
+
+describe("isEditTool", () => {
+  it("recognizes common edit tool names across backends", () => {
+    expect(isEditTool("patch_apply")).toBe(true);
+    expect(isEditTool("edit_file")).toBe(true);
+    expect(isEditTool("file_edit")).toBe(true);
+    expect(isEditTool("MultiEdit")).toBe(true);
+    expect(isEditTool("Write File")).toBe(true);
+  });
+
+  it("does not classify non-edit tools as edit tools", () => {
+    expect(isEditTool("exec_command")).toBe(false);
+    expect(isEditTool("terminal")).toBe(false);
+    expect(isEditTool("search_files")).toBe(false);
+    expect(isEditTool(undefined)).toBe(false);
+  });
+});
+
+describe("looksLikeUnifiedDiff", () => {
+  it("returns true for valid unified diff text", () => {
+    const diff = [
+      "--- a/file.txt",
+      "+++ b/file.txt",
+      "@@ -1 +1 @@",
+      "-old line",
+      "+new line",
+    ].join("\n");
+    expect(looksLikeUnifiedDiff(diff)).toBe(true);
+  });
+
+  it("returns false for non-diff text", () => {
+    expect(looksLikeUnifiedDiff("plain output")).toBe(false);
+    expect(looksLikeUnifiedDiff("")).toBe(false);
+    expect(looksLikeUnifiedDiff(undefined)).toBe(false);
+  });
+});
diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts
index d0d97ba4f1..0c7604146f 100644
--- a/packages/views/common/task-transcript/build-timeline.ts
+++ b/packages/views/common/task-transcript/build-timeline.ts
@@ -11,6 +11,59 @@ export interface TimelineItem {
   output?: string;
 }
 
+const EDIT_TOOL_NAMES = new Set([
+  "patch_apply",
+  "patch",
+  "apply_patch",
+  "edit",
+  "edit_file",
+  "write",
+  "write_file",
+  "multiedit",
+  "multi_edit",
+  "file_edit",
+  "str_replace_editor",
+  "insert",
+  "replace",
+  "file_change",
+  "filechange",
+]);
+
+function normalizeToolName(tool: string): string {
+  return tool
+    .trim()
+    .toLowerCase()
+    .replace(/[\s-]+/g, "_");
+}
+
+export function isEditTool(tool?: string): boolean {
+  if (!tool) return false;
+  const normalized = normalizeToolName(tool);
+  if (EDIT_TOOL_NAMES.has(normalized)) return true;
+  return (
+    normalized.includes("edit") ||
+    normalized.includes("patch") ||
+    normalized.includes("write_file")
+  );
+}
+
+export function looksLikeUnifiedDiff(output?: string): boolean {
+  if (!output) return false;
+  let hasFileHeader = false;
+  let hasHunk = false;
+  let hasChangeLine = false;
+
+  for (const line of output.split("\n")) {
+    if (line.startsWith("--- ") || line.startsWith("+++ ")) hasFileHeader = true;
+    if (line.startsWith("@@ ")) hasHunk = true;
+    if ((line.startsWith("+") && !line.startsWith("+++ ")) || (line.startsWith("-") && !line.startsWith("--- "))) {
+      hasChangeLine = true;
+    }
+  }
+
+  return hasChangeLine && (hasFileHeader || hasHunk);
+}
+
 /** Build a chronologically ordered timeline from raw task messages. */
 export function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
   const items: TimelineItem[] = [];
diff --git a/packages/views/common/task-transcript/diff-viewer.test.tsx b/packages/views/common/task-transcript/diff-viewer.test.tsx
new file mode 100644
index 0000000000..4427466eda
--- /dev/null
+++ b/packages/views/common/task-transcript/diff-viewer.test.tsx
@@ -0,0 +1,59 @@
+import { type ReactNode } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { I18nProvider } from "@multica/core/i18n/react";
+import enCommon from "../../locales/en/common.json";
+import enAgents from "../../locales/en/agents.json";
+import { DiffViewer } from "./diff-viewer";
+
+const TEST_RESOURCES = {
+  en: {
+    common: enCommon,
+    agents: enAgents,
+  },
+};
+
+function I18nWrapper({ children }: { children: ReactNode }) {
+  return (
+    
+      {children}
+    
+  );
+}
+
+describe("DiffViewer", () => {
+  it("renders unified diff and switches to split mode", async () => {
+    const user = userEvent.setup();
+    render(
+      ,
+      { wrapper: I18nWrapper },
+    );
+
+    expect(screen.getByText("Unified")).toBeInTheDocument();
+    expect(screen.getByText("Split")).toBeInTheDocument();
+    expect(screen.getByText("-old line")).toBeInTheDocument();
+    expect(screen.getByText("+new line")).toBeInTheDocument();
+
+    await user.click(screen.getByRole("button", { name: "Split" }));
+
+    expect(screen.getByText("old line")).toBeInTheDocument();
+    expect(screen.getByText("new line")).toBeInTheDocument();
+  });
+
+  it("shows placeholder when no visual diff can be parsed", () => {
+    render(, { wrapper: I18nWrapper });
+
+    expect(
+      screen.getByText("No visual diff available for this file change."),
+    ).toBeInTheDocument();
+  });
+});
diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx
new file mode 100644
index 0000000000..ca8818d4a0
--- /dev/null
+++ b/packages/views/common/task-transcript/diff-viewer.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
+import { ToggleGroup, ToggleGroupItem } from "@multica/ui/components/ui/toggle-group";
+import { useT } from "../../i18n";
+
+type DiffViewMode = "unified" | "split";
+
+interface DiffViewerProps {
+  output?: string;
+  oldText?: string;
+  newText?: string;
+  defaultMode?: DiffViewMode;
+}
+
+interface DiffLine {
+  type: "add" | "del" | "context" | "hunk" | "file";
+  text: string;
+}
+
+interface SplitRow {
+  type: "add" | "del" | "context" | "pair" | "hunk" | "file";
+  left: string;
+  right: string;
+}
+
+function parseUnifiedDiff(text: string): DiffLine[] {
+  const lines: DiffLine[] = [];
+  for (const line of text.split("\n")) {
+    if (line.startsWith("--- ") || line.startsWith("+++ ")) {
+      lines.push({ type: "file", text: line });
+      continue;
+    }
+    if (line.startsWith("@@ ")) {
+      lines.push({ type: "hunk", text: line });
+      continue;
+    }
+    if (line.startsWith("+") && !line.startsWith("+++ ")) {
+      lines.push({ type: "add", text: line });
+      continue;
+    }
+    if (line.startsWith("-") && !line.startsWith("--- ")) {
+      lines.push({ type: "del", text: line });
+      continue;
+    }
+    lines.push({ type: "context", text: line });
+  }
+  return lines;
+}
+
+function buildDiffFromOldNew(oldText: string, newText: string): string {
+  const oldLines = oldText.split("\n");
+  const newLines = newText.split("\n");
+  const lines: string[] = [
+    "--- a/file",
+    "+++ b/file",
+    `@@ -1,${oldLines.length} +1,${newLines.length} @@`,
+    ...oldLines.map((line) => `-${line}`),
+    ...newLines.map((line) => `+${line}`),
+  ];
+  return lines.join("\n");
+}
+
+function stripDiffPrefix(line: string, type: DiffLine["type"]): string {
+  if (type === "add" || type === "del") {
+    return line.slice(1);
+  }
+  if (type === "context" && line.startsWith(" ")) {
+    return line.slice(1);
+  }
+  return line;
+}
+
+function buildSplitRows(lines: DiffLine[]): SplitRow[] {
+  const rows: SplitRow[] = [];
+  let i = 0;
+  while (i < lines.length) {
+    const current = lines[i]!;
+
+    if (current.type === "file" || current.type === "hunk") {
+      rows.push({
+        type: current.type,
+        left: current.text,
+        right: current.text,
+      });
+      i += 1;
+      continue;
+    }
+
+    if (current.type === "del") {
+      const next = lines[i + 1];
+      if (next?.type === "add") {
+        rows.push({
+          type: "pair",
+          left: stripDiffPrefix(current.text, "del"),
+          right: stripDiffPrefix(next.text, "add"),
+        });
+        i += 2;
+        continue;
+      }
+      rows.push({
+        type: "del",
+        left: stripDiffPrefix(current.text, "del"),
+        right: "",
+      });
+      i += 1;
+      continue;
+    }
+
+    if (current.type === "add") {
+      rows.push({
+        type: "add",
+        left: "",
+        right: stripDiffPrefix(current.text, "add"),
+      });
+      i += 1;
+      continue;
+    }
+
+    rows.push({
+      type: "context",
+      left: stripDiffPrefix(current.text, "context"),
+      right: stripDiffPrefix(current.text, "context"),
+    });
+    i += 1;
+  }
+  return rows;
+}
+
+export function DiffViewer({
+  output,
+  oldText,
+  newText,
+  defaultMode = "unified",
+}: DiffViewerProps) {
+  const { t } = useT("agents");
+  const [expanded, setExpanded] = useState(false);
+  const [copied, setCopied] = useState(false);
+  const [mode, setMode] = useState(defaultMode);
+
+  const diffText = useMemo(() => {
+    if (output && output.length > 0) return output;
+    if (oldText != null || newText != null) {
+      return buildDiffFromOldNew(oldText ?? "", newText ?? "");
+    }
+    return "";
+  }, [output, oldText, newText]);
+
+  const lines = useMemo(() => parseUnifiedDiff(diffText), [diffText]);
+  const hasVisualDiff = lines.some((line) => line.type === "add" || line.type === "del");
+  const isLong = lines.length > 100;
+  const displayLines = expanded || !isLong ? lines : lines.slice(0, 100);
+  const splitRows = useMemo(() => buildSplitRows(displayLines), [displayLines]);
+  const truncated = !expanded && isLong;
+
+  const handleCopy = async () => {
+    await navigator.clipboard.writeText(diffText);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  return (
+    
+
+ + {hasVisualDiff ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)} + +
+ { + if (value === "unified" || value === "split") setMode(value); + }} + size="sm" + variant="outline" + spacing={0} + aria-label={t(($) => $.transcript.file_changes)} + > + $.transcript.diff_unified)} className="px-2 text-[10px]"> + {t(($) => $.transcript.diff_unified)} + + $.transcript.diff_split)} className="px-2 text-[10px]"> + {t(($) => $.transcript.diff_split)} + + + +
+
+ + {!hasVisualDiff ? ( +
+ {t(($) => $.transcript.no_visual_diff)} +
+ ) : ( +
+ {mode === "unified" ? ( +
+ {displayLines.map((line, i) => ( +
+ {line.text} +
+ ))} +
+ ) : ( + + + {splitRows.map((row, i) => { + if (row.type === "file" || row.type === "hunk") { + return ( + + + + ); + } + + return ( + + + + + ); + })} + +
+ {row.left} +
+ {row.left} + + {row.right} +
+ )} + + {truncated && ( + + )} + {expanded && isLong && ( + + )} +
+ )} +
+ ); +} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 327755cad5..25629be058 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -346,6 +346,13 @@ "copy_all": "Copy all", "copy_filtered": "Copy filtered", "copied": "Copied", + "file_changes": "File changes", + "file_content": "File content", + "diff_unified": "Unified", + "diff_split": "Split", + "no_visual_diff": "No visual diff available for this file change.", + "show_all_lines": "Show all {{count}} lines", + "collapse": "Collapse", "waiting_events": "Waiting for events...", "no_data": "No execution data recorded." }, diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 516b96450e..bcf0d2ff6f 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -338,6 +338,13 @@ "copy_all": "全部复制", "copy_filtered": "复制筛选结果", "copied": "已复制", + "file_changes": "文件变更", + "file_content": "文件内容", + "diff_unified": "统一差异", + "diff_split": "拆分差异", + "no_visual_diff": "该次文件修改未提供可视化差异。", + "show_all_lines": "显示全部 {{count}} 行", + "collapse": "收起", "waiting_events": "等待事件中...", "no_data": "未记录执行数据。" }, diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 72d7a5691a..b08ad72b8b 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -455,6 +455,9 @@ type codexClient struct { turnErrorMu sync.Mutex turnError string // captured from turn/completed status=failed or terminal error notifications + + fileChangeDeltaMu sync.Mutex + fileChangeDeltas map[string]string } func (c *codexClient) setTurnError(msg string) { @@ -739,11 +742,13 @@ func (c *codexClient) handleEvent(msg map[string]any) { } case "patch_apply_end": callID, _ := msg["call_id"].(string) + output, _ := msg["output"].(string) if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolResult, Tool: "patch_apply", CallID: callID, + Output: output, }) } case "task_complete": @@ -886,6 +891,7 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an } case method == "item/started" && itemType == "fileChange": + c.clearFileChangeDelta(itemID) if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolUse, @@ -894,12 +900,25 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an }) } + case method == "item/fileChange/outputDelta" && itemType == "fileChange": + delta, _ := params["delta"].(string) + c.appendFileChangeDelta(itemID, delta) + case method == "item/completed" && itemType == "fileChange": + output := c.popFileChangeDelta(itemID) + if output == "" { + if aggregatedOutput, ok := item["aggregatedOutput"].(string); ok { + output = aggregatedOutput + } else if inlineOutput, ok := item["output"].(string); ok { + output = inlineOutput + } + } if c.onMessage != nil { c.onMessage(Message{ Type: MessageToolResult, Tool: "patch_apply", CallID: itemID, + Output: output, }) } @@ -939,6 +958,45 @@ func describeCodexItemProgressActivity(method, itemType, itemID string) string { return fmt.Sprintf("%s:%s:%s", method, itemType, itemID) } +func (c *codexClient) clearFileChangeDelta(itemID string) { + if itemID == "" { + return + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + c.fileChangeDeltas = make(map[string]string) + return + } + delete(c.fileChangeDeltas, itemID) +} + +func (c *codexClient) appendFileChangeDelta(itemID, delta string) { + if itemID == "" || delta == "" { + return + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + c.fileChangeDeltas = make(map[string]string) + } + c.fileChangeDeltas[itemID] += delta +} + +func (c *codexClient) popFileChangeDelta(itemID string) string { + if itemID == "" { + return "" + } + c.fileChangeDeltaMu.Lock() + defer c.fileChangeDeltaMu.Unlock() + if c.fileChangeDeltas == nil { + return "" + } + output := c.fileChangeDeltas[itemID] + delete(c.fileChangeDeltas, itemID) + return output +} + // extractUsageFromMap extracts token usage from a map that may contain // "usage", "token_usage", or "tokens" fields. Handles various Codex formats. func (c *codexClient) extractUsageFromMap(data map[string]any) { diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index 7e6d88a63e..e8b85eb6d1 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -515,6 +515,58 @@ func TestCodexRawItemCommandExecution(t *testing.T) { } } +func TestCodexRawItemFileChangeAggregatesOutputDelta(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"item":{"type":"fileChange","id":"patch-1"},"delta":"--- a/a.txt\n+++ b/a.txt\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"item":{"type":"fileChange","id":"patch-1"},"delta":"@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[0].Type != MessageToolUse || messages[0].Tool != "patch_apply" || messages[0].CallID != "patch-1" { + t.Fatalf("unexpected start message: %+v", messages[0]) + } + if messages[1].Type != MessageToolResult || messages[1].Tool != "patch_apply" || messages[1].CallID != "patch-1" { + t.Fatalf("unexpected complete message: %+v", messages[1]) + } + if messages[1].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected aggregated diff output: %q", messages[1].Output) + } +} + +func TestCodexRawItemFileChangeUsesAggregatedOutputFallback(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"fileChange","id":"patch-1"}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"fileChange","id":"patch-1","aggregatedOutput":"patched: a.txt"}}}`) + + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if messages[1].Type != MessageToolResult || messages[1].Output != "patched: a.txt" { + t.Fatalf("unexpected complete message: %+v", messages[1]) + } +} + func TestCodexRawItemAgentMessageFinalAnswer(t *testing.T) { t.Parallel() From 7025fabba57bcef27e44a9afbc603bfce75b04e3 Mon Sep 17 00:00:00 2001 From: WSRer <1749094641@qq.com> Date: Wed, 13 May 2026 09:27:05 +0800 Subject: [PATCH 10/18] fix(transcript): render new-file header-only diffs as file changes --- .../task-transcript/build-timeline.test.ts | 9 +++++ .../common/task-transcript/build-timeline.ts | 9 +++-- .../task-transcript/diff-viewer.test.tsx | 39 ++++++++++++++++--- .../common/task-transcript/diff-viewer.tsx | 11 +++++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/views/common/task-transcript/build-timeline.test.ts b/packages/views/common/task-transcript/build-timeline.test.ts index 2ac6a4d57b..c1b08edef9 100644 --- a/packages/views/common/task-transcript/build-timeline.test.ts +++ b/packages/views/common/task-transcript/build-timeline.test.ts @@ -30,6 +30,15 @@ describe("looksLikeUnifiedDiff", () => { expect(looksLikeUnifiedDiff(diff)).toBe(true); }); + it("returns true for new-file style diff headers without hunks", () => { + const headerOnly = [ + "--- src/new-file.ts", + "+++ src/new-file.ts", + "(new file, 42 bytes)", + ].join("\n"); + expect(looksLikeUnifiedDiff(headerOnly)).toBe(true); + }); + it("returns false for non-diff text", () => { expect(looksLikeUnifiedDiff("plain output")).toBe(false); expect(looksLikeUnifiedDiff("")).toBe(false); diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts index 0c7604146f..e7f4e7e675 100644 --- a/packages/views/common/task-transcript/build-timeline.ts +++ b/packages/views/common/task-transcript/build-timeline.ts @@ -49,19 +49,22 @@ export function isEditTool(tool?: string): boolean { export function looksLikeUnifiedDiff(output?: string): boolean { if (!output) return false; - let hasFileHeader = false; + let hasOldFileHeader = false; + let hasNewFileHeader = false; let hasHunk = false; let hasChangeLine = false; for (const line of output.split("\n")) { - if (line.startsWith("--- ") || line.startsWith("+++ ")) hasFileHeader = true; + if (line.startsWith("--- ")) hasOldFileHeader = true; + if (line.startsWith("+++ ")) hasNewFileHeader = true; if (line.startsWith("@@ ")) hasHunk = true; if ((line.startsWith("+") && !line.startsWith("+++ ")) || (line.startsWith("-") && !line.startsWith("--- "))) { hasChangeLine = true; } } - return hasChangeLine && (hasFileHeader || hasHunk); + if (hasOldFileHeader && hasNewFileHeader) return true; + return hasChangeLine && hasHunk; } /** Build a chronologically ordered timeline from raw task messages. */ diff --git a/packages/views/common/task-transcript/diff-viewer.test.tsx b/packages/views/common/task-transcript/diff-viewer.test.tsx index 4427466eda..83abf744c0 100644 --- a/packages/views/common/task-transcript/diff-viewer.test.tsx +++ b/packages/views/common/task-transcript/diff-viewer.test.tsx @@ -1,6 +1,5 @@ import { type ReactNode } from "react"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { I18nProvider } from "@multica/core/i18n/react"; import enCommon from "../../locales/en/common.json"; @@ -23,8 +22,7 @@ function I18nWrapper({ children }: { children: ReactNode }) { } describe("DiffViewer", () => { - it("renders unified diff and switches to split mode", async () => { - const user = userEvent.setup(); + it("renders unified and split diff modes", () => { render( { expect(screen.getByText("Split")).toBeInTheDocument(); expect(screen.getByText("-old line")).toBeInTheDocument(); expect(screen.getByText("+new line")).toBeInTheDocument(); + expect(screen.queryByText("old line")).not.toBeInTheDocument(); + expect(screen.queryByText("new line")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Split" })); - + render( + , + { wrapper: I18nWrapper }, + ); expect(screen.getByText("old line")).toBeInTheDocument(); expect(screen.getByText("new line")).toBeInTheDocument(); }); @@ -56,4 +67,22 @@ describe("DiffViewer", () => { screen.getByText("No visual diff available for this file change."), ).toBeInTheDocument(); }); + + it("renders simplified diff card for new-file headers without +/- hunks", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + expect(screen.getByText("--- src/new-file.ts")).toBeInTheDocument(); + expect(screen.getByText("(new file, 42 bytes)")).toBeInTheDocument(); + }); }); diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx index ca8818d4a0..5dfcb55ce6 100644 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -149,6 +149,13 @@ export function DiffViewer({ const lines = useMemo(() => parseUnifiedDiff(diffText), [diffText]); const hasVisualDiff = lines.some((line) => line.type === "add" || line.type === "del"); + const hasDiffStructure = lines.some( + (line) => + line.type === "add" || + line.type === "del" || + line.type === "file" || + line.type === "hunk", + ); const isLong = lines.length > 100; const displayLines = expanded || !isLong ? lines : lines.slice(0, 100); const splitRows = useMemo(() => buildSplitRows(displayLines), [displayLines]); @@ -164,7 +171,7 @@ export function DiffViewer({
- {hasVisualDiff ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)} + {hasDiffStructure ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)}
- {!hasVisualDiff ? ( + {!hasDiffStructure ? (
{t(($) => $.transcript.no_visual_diff)}
From 3957d4dbb9820525d458486bc7bc44fd2a61b19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Wed, 13 May 2026 10:45:22 +0800 Subject: [PATCH 11/18] fix(transcript): render tool_use diffs and restore diff mode toggle --- .../agent-transcript-dialog.test.tsx | 135 ++++++++++++++++++ .../agent-transcript-dialog.tsx | 50 +++++-- .../task-transcript/diff-viewer.test.tsx | 36 ++++- .../common/task-transcript/diff-viewer.tsx | 69 +++++---- packages/views/locales/en/agents.json | 2 + packages/views/locales/zh-Hans/agents.json | 2 + 6 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 packages/views/common/task-transcript/agent-transcript-dialog.test.tsx diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx new file mode 100644 index 0000000000..6ebf565e6b --- /dev/null +++ b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx @@ -0,0 +1,135 @@ +import { type ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enAgents from "../../locales/en/agents.json"; +import { AgentTranscriptDialog } from "./agent-transcript-dialog"; +import type { TimelineItem } from "./build-timeline"; +import type { AgentTask } from "@multica/core/types/agent"; + +vi.mock("@multica/core/api", () => ({ + api: { + getAgent: vi.fn().mockResolvedValue(null), + listRuntimes: vi.fn().mockResolvedValue([]), + }, +})); +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", + useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }), +})); + +const TEST_RESOURCES = { + en: { + common: enCommon, + agents: enAgents, + }, +}; + +function I18nWrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ( + + + {children} + + + ); +} + +function baseTask(): AgentTask { + return { + id: "task-1", + agent_id: "agent-1", + runtime_id: "runtime-1", + issue_id: "issue-1", + status: "completed", + created_at: "2026-05-13T00:00:00Z", + started_at: "2026-05-13T00:00:10Z", + completed_at: "2026-05-13T00:00:20Z", + session_id: null, + result: null, + work_dir: null, + dispatch_after: null, + dispatch_attempts: 0, + dispatched_at: null, + trigger_comment_id: null, + trigger_summary: null, + trigger_author_type: null, + trigger_author_name: null, + force_fresh_session: false, + }; +} + +describe("AgentTranscriptDialog tool_use diff rendering", () => { + it("renders diff for create-file tool_use with content + file_path", () => { + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_use", + tool: "write_file", + input: { + file_path: "E:/workspace/tests/readme.txt", + content: "hello\nworld\n", + }, + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Claude" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText(".../tests/readme.txt")); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.getByText("--- E:/workspace/tests/readme.txt")).toBeInTheDocument(); + expect(screen.getByText("+hello")).toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + }); + + it("renders diff for replace tool_use with old_string + new_string", () => { + const items: TimelineItem[] = [ + { + seq: 1, + type: "tool_use", + tool: "edit_file", + input: { + file_path: "E:/workspace/tests/hello.txt", + old_string: "before", + new_string: "after", + replace_all: false, + }, + }, + ]; + + render( + {}} + task={baseTask()} + items={items} + agentName="Claude" + />, + { wrapper: I18nWrapper }, + ); + + fireEvent.click(screen.getByText(".../tests/hello.txt")); + + expect(screen.getByText("File changes")).toBeInTheDocument(); + expect(screen.getByText("-before")).toBeInTheDocument(); + expect(screen.getByText("+after")).toBeInTheDocument(); + expect(screen.queryByText("No visual diff available for this file change.")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 58b9c1e84a..fa8031b4cd 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -579,6 +579,37 @@ interface TranscriptEventRowProps { isSelected: boolean; } +interface ToolUseDiffPayload { + filePath?: string; + oldText?: string; + newText?: string; +} + +function toolUseDiffPayload(input: Record | undefined): ToolUseDiffPayload | null { + if (!input) return null; + + const filePath = + (typeof input.file_path === "string" && input.file_path) || + (typeof input.path === "string" && input.path) || + undefined; + + const oldText = typeof input.old_text === "string" + ? input.old_text + : typeof input.old_string === "string" + ? input.old_string + : undefined; + const newText = typeof input.new_text === "string" + ? input.new_text + : typeof input.new_string === "string" + ? input.new_string + : typeof input.content === "string" + ? input.content + : undefined; + + if (oldText == null && newText == null) return null; + return { filePath, oldText, newText }; +} + const TranscriptEventRow = ({ ref, item, @@ -588,14 +619,12 @@ const TranscriptEventRow = ({ const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); + const parsedToolUseDiff = + item.type === "tool_use" && isEditTool(item.tool) && item.input + ? toolUseDiffPayload(item.input) + : null; const toolUseHasInlineDiff = - item.type === "tool_use" && - isEditTool(item.tool) && - item.input != null && - ( - typeof item.input.old_text === "string" || - typeof item.input.new_text === "string" - ); + item.type === "tool_use" && parsedToolUseDiff != null; const hasDetail = (item.type === "tool_use" && ( @@ -682,10 +711,9 @@ function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { case "tool_use": { if (isEditTool(item.tool) && item.input) { - const oldText = typeof item.input.old_text === "string" ? item.input.old_text : undefined; - const newText = typeof item.input.new_text === "string" ? item.input.new_text : undefined; - if (oldText != null || newText != null) { - return ; + const parsed = toolUseDiffPayload(item.input); + if (parsed) { + return ; } } return ( diff --git a/packages/views/common/task-transcript/diff-viewer.test.tsx b/packages/views/common/task-transcript/diff-viewer.test.tsx index 83abf744c0..d6a97020b9 100644 --- a/packages/views/common/task-transcript/diff-viewer.test.tsx +++ b/packages/views/common/task-transcript/diff-viewer.test.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { I18nProvider } from "@multica/core/i18n/react"; import enCommon from "../../locales/en/common.json"; @@ -36,8 +36,9 @@ describe("DiffViewer", () => { { wrapper: I18nWrapper }, ); - expect(screen.getByText("Unified")).toBeInTheDocument(); - expect(screen.getByText("Split")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Switch to split diff view" }), + ).toBeInTheDocument(); expect(screen.getByText("-old line")).toBeInTheDocument(); expect(screen.getByText("+new line")).toBeInTheDocument(); expect(screen.queryByText("old line")).not.toBeInTheDocument(); @@ -56,10 +57,39 @@ describe("DiffViewer", () => { />, { wrapper: I18nWrapper }, ); + expect( + screen.getByRole("button", { name: "Switch to unified diff view" }), + ).toBeInTheDocument(); expect(screen.getByText("old line")).toBeInTheDocument(); expect(screen.getByText("new line")).toBeInTheDocument(); }); + it("switches mode when clicking the toggle", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + expect(screen.getByText("-old line")).toBeInTheDocument(); + expect(screen.queryByText("old line")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Switch to split diff view" })); + + expect(screen.getByText("old line")).toBeInTheDocument(); + expect(screen.getByText("new line")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Switch to unified diff view" }), + ).toBeInTheDocument(); + }); + it("shows placeholder when no visual diff can be parsed", () => { render(, { wrapper: I18nWrapper }); diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx index 5dfcb55ce6..3f72c44dba 100644 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -1,8 +1,19 @@ "use client"; import { useMemo, useState } from "react"; -import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react"; -import { ToggleGroup, ToggleGroupItem } from "@multica/ui/components/ui/toggle-group"; +import { + Check, + ChevronDown, + ChevronUp, + Copy, + SquareSplitHorizontal, + SquareSplitVertical, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip"; import { useT } from "../../i18n"; type DiffViewMode = "unified" | "split"; @@ -11,6 +22,7 @@ interface DiffViewerProps { output?: string; oldText?: string; newText?: string; + filePath?: string; defaultMode?: DiffViewMode; } @@ -49,12 +61,13 @@ function parseUnifiedDiff(text: string): DiffLine[] { return lines; } -function buildDiffFromOldNew(oldText: string, newText: string): string { +function buildDiffFromOldNew(oldText: string, newText: string, filePath?: string): string { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); + const path = filePath ?? "file"; const lines: string[] = [ - "--- a/file", - "+++ b/file", + `--- ${path}`, + `+++ ${path}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`, ...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`), @@ -132,23 +145,24 @@ export function DiffViewer({ output, oldText, newText, + filePath, defaultMode = "unified", }: DiffViewerProps) { const { t } = useT("agents"); const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); const [mode, setMode] = useState(defaultMode); + const nextMode: DiffViewMode = mode === "unified" ? "split" : "unified"; const diffText = useMemo(() => { - if (output && output.length > 0) return output; if (oldText != null || newText != null) { - return buildDiffFromOldNew(oldText ?? "", newText ?? ""); + return buildDiffFromOldNew(oldText ?? "", newText ?? "", filePath); } + if (output && output.length > 0) return output; return ""; - }, [output, oldText, newText]); + }, [output, oldText, newText, filePath]); const lines = useMemo(() => parseUnifiedDiff(diffText), [diffText]); - const hasVisualDiff = lines.some((line) => line.type === "add" || line.type === "del"); const hasDiffStructure = lines.some( (line) => line.type === "add" || @@ -156,6 +170,10 @@ export function DiffViewer({ line.type === "file" || line.type === "hunk", ); + const toggleDiffLabel = + nextMode === "split" + ? t(($) => $.transcript.switch_to_diff_split) + : t(($) => $.transcript.switch_to_diff_unified); const isLong = lines.length > 100; const displayLines = expanded || !isLong ? lines : lines.slice(0, 100); const splitRows = useMemo(() => buildSplitRows(displayLines), [displayLines]); @@ -174,24 +192,21 @@ export function DiffViewer({ {hasDiffStructure ? t(($) => $.transcript.file_changes) : t(($) => $.transcript.file_content)}
- { - if (value === "unified" || value === "split") setMode(value); - }} - size="sm" - variant="outline" - spacing={0} - aria-label={t(($) => $.transcript.file_changes)} - > - $.transcript.diff_unified)} className="px-2 text-[10px]"> - {t(($) => $.transcript.diff_unified)} - - $.transcript.diff_split)} className="px-2 text-[10px]"> - {t(($) => $.transcript.diff_split)} - - + + } + aria-label={toggleDiffLabel} + className="flex size-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={() => setMode(nextMode)} + > + {nextMode === "split" ? ( + + ) : ( + + )} + + {toggleDiffLabel} + + + } + aria-label={copyLabel} + className={ + copyFailed + ? "text-destructive transition-colors hover:text-destructive" + : "text-muted-foreground transition-colors hover:text-foreground" + } + onClick={handleCopy} + > + {copied ? : } + + {copyLabel} +
diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index da9474b4dd..1523068f4a 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -345,6 +345,8 @@ "events_filtered": "{{shown}} of {{total}} events", "copy_all": "Copy all", "copy_filtered": "Copy filtered", + "copy_diff": "Copy diff", + "copy_failed": "Copy failed", "copied": "Copied", "file_changes": "File changes", "file_content": "File content", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 958da14e51..e3f1889ba8 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -337,6 +337,8 @@ "events_filtered": "{{shown}} / {{total}} 个事件", "copy_all": "全部复制", "copy_filtered": "复制筛选结果", + "copy_diff": "复制差异", + "copy_failed": "复制失败", "copied": "已复制", "file_changes": "文件变更", "file_content": "文件内容", diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index b08ad72b8b..21880eed2b 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -458,6 +458,7 @@ type codexClient struct { fileChangeDeltaMu sync.Mutex fileChangeDeltas map[string]string + lastTurnDiffs map[string]string } func (c *codexClient) setTurnError(msg string) { @@ -684,8 +685,10 @@ func (c *codexClient) handleNotification(raw map[string]json.RawMessage) { // Raw v2 notifications if c.notificationProtocol != "legacy" { if c.notificationProtocol == "unknown" && - (method == "turn/started" || method == "turn/completed" || - method == "thread/started" || strings.HasPrefix(method, "item/")) { + (strings.HasPrefix(method, "turn/") || + strings.HasPrefix(method, "thread/") || + strings.HasPrefix(method, "item/") || + method == "error") { c.notificationProtocol = "raw" } @@ -788,6 +791,17 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any c.onMessage(Message{Type: MessageStatus, Status: "running", SessionID: c.threadID}) } + case "turn/diff/updated": + if c.onSemanticActivity != nil { + c.onSemanticActivity("turn/diff/updated") + } + turnID, _ := params["turnId"].(string) + if turnID == "" { + turnID = c.turnID + } + diff, _ := params["diff"].(string) + c.emitTurnDiffUpdated(turnID, diff) + case "turn/completed": turnID := extractNestedString(params, "turn", "id") status := extractNestedString(params, "turn", "status") @@ -997,6 +1011,43 @@ func (c *codexClient) popFileChangeDelta(itemID string) string { return output } +func (c *codexClient) emitTurnDiffUpdated(turnID, diff string) { + if diff == "" { + return + } + key := turnID + if key == "" { + key = "_unknown" + } + + c.fileChangeDeltaMu.Lock() + if c.lastTurnDiffs == nil { + c.lastTurnDiffs = make(map[string]string) + } + if c.lastTurnDiffs[key] == diff { + c.fileChangeDeltaMu.Unlock() + return + } + c.lastTurnDiffs[key] = diff + c.fileChangeDeltaMu.Unlock() + + if c.onMessage != nil { + c.onMessage(Message{ + Type: MessageToolResult, + Tool: "patch_apply", + CallID: codexTurnDiffCallID(turnID), + Output: diff, + }) + } +} + +func codexTurnDiffCallID(turnID string) string { + if turnID == "" { + return "turn-diff" + } + return turnID + ":diff" +} + // extractUsageFromMap extracts token usage from a map that may contain // "usage", "token_usage", or "tokens" fields. Handles various Codex formats. func (c *codexClient) extractUsageFromMap(data map[string]any) { diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index e8b85eb6d1..405333b0b1 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -545,6 +545,34 @@ func TestCodexRawItemFileChangeAggregatesOutputDelta(t *testing.T) { } } +func TestCodexRawTurnDiffUpdatedEmitsPatchResult(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-other","turnId":"turn-2","diff":"--- a/b.txt\n+++ b/b.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + + if len(messages) != 1 { + t.Fatalf("expected one deduplicated patch diff message, got %d: %+v", len(messages), messages) + } + if messages[0].Type != MessageToolResult || messages[0].Tool != "patch_apply" || messages[0].CallID != "turn-1:diff" { + t.Fatalf("unexpected diff message: %+v", messages[0]) + } + if messages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", messages[0].Output) + } +} + func TestCodexRawItemFileChangeUsesAggregatedOutputFallback(t *testing.T) { t.Parallel() @@ -1213,7 +1241,7 @@ func TestCodexExecuteSemanticInactivityAllowsContinuousDeltaProgress(t *testing. `sleep 0.05`+"\n"+ `echo '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thr-delta","item":{"type":"agentMessage","id":"msg-1"},"delta":"thinking"}}'`+"\n"+ `sleep 0.05`+"\n"+ - `echo '{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"threadId":"thr-delta","item":{"type":"fileChange","id":"patch-1"},"delta":"patched"}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-delta","turnId":"turn-delta","diff":"--- a/a.txt\n+++ b/a.txt\n"}}'`+"\n"+ `sleep 0.05`+"\n"+ `echo '{"jsonrpc":"2.0","method":"item/mcpToolCall/progress","params":{"threadId":"thr-delta","item":{"type":"mcpToolCall","id":"mcp-1"},"progress":{"message":"still running"}}}'`+"\n"+ `sleep 0.05`+"\n"+ From 58fedd96c136a1b5a1b3af648a26716362279666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=88=99=E6=96=8C?= <1749094641@qq.com> Date: Fri, 22 May 2026 19:01:10 +0800 Subject: [PATCH 17/18] fix(agent): preserve diff snapshots and whitespace --- .../task-transcript/diff-viewer.test.tsx | 26 +++++++++++++++ .../common/task-transcript/diff-viewer.tsx | 12 ++++--- server/pkg/agent/codex.go | 3 -- server/pkg/agent/codex_test.go | 32 +++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/views/common/task-transcript/diff-viewer.test.tsx b/packages/views/common/task-transcript/diff-viewer.test.tsx index 400681e1d4..634db40f85 100644 --- a/packages/views/common/task-transcript/diff-viewer.test.tsx +++ b/packages/views/common/task-transcript/diff-viewer.test.tsx @@ -203,4 +203,30 @@ describe("DiffViewer", () => { expect(rows[1]![0]).toHaveTextContent("old two"); expect(rows[1]![1]).toHaveTextContent("new two"); }); + + it("preserves indentation in split diff cells", () => { + render( + , + { wrapper: I18nWrapper }, + ); + + const row = screen + .getAllByRole("row") + .map((tableRow) => within(tableRow).queryAllByRole("cell")) + .find((cells) => cells.length === 2 && cells[0]?.textContent?.startsWith(" old")); + + expect(row?.[0]).toHaveTextContent(" old: value", { normalizeWhitespace: false }); + expect(row?.[1]).toHaveTextContent(" new: value", { normalizeWhitespace: false }); + expect(row?.[0]).toHaveClass("whitespace-pre-wrap", "break-all"); + expect(row?.[1]).toHaveClass("whitespace-pre-wrap", "break-all"); + }); }); diff --git a/packages/views/common/task-transcript/diff-viewer.tsx b/packages/views/common/task-transcript/diff-viewer.tsx index 078a822c7d..6f4013cc2d 100644 --- a/packages/views/common/task-transcript/diff-viewer.tsx +++ b/packages/views/common/task-transcript/diff-viewer.tsx @@ -37,6 +37,8 @@ interface SplitRow { right: string; } +const splitCellBaseClass = "w-1/2 whitespace-pre-wrap break-all px-1 py-0.5"; + function parseUnifiedDiff(text: string): DiffLine[] { const lines: DiffLine[] = []; for (const line of text.split("\n")) { @@ -308,7 +310,7 @@ export function DiffViewer({ if (row.type === "file" || row.type === "hunk") { return ( - + {row.left} @@ -320,8 +322,8 @@ export function DiffViewer({ {row.left} @@ -329,8 +331,8 @@ export function DiffViewer({ {row.right} diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 21880eed2b..dba8a041ae 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -1012,9 +1012,6 @@ func (c *codexClient) popFileChangeDelta(itemID string) string { } func (c *codexClient) emitTurnDiffUpdated(turnID, diff string) { - if diff == "" { - return - } key := turnID if key == "" { key = "_unknown" diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index 405333b0b1..b8a22b9c50 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -573,6 +573,38 @@ func TestCodexRawTurnDiffUpdatedEmitsPatchResult(t *testing.T) { } } +func TestCodexRawTurnDiffUpdatedEmitsEmptySnapshotAfterNonEmpty(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + + if len(messages) != 2 { + t.Fatalf("expected non-empty diff and one empty clearing snapshot, got %d: %+v", len(messages), messages) + } + if messages[0].Output == "" { + t.Fatalf("expected first emitted diff to be non-empty: %+v", messages[0]) + } + if messages[1].Type != MessageToolResult || messages[1].Tool != "patch_apply" || messages[1].CallID != "turn-1:diff" { + t.Fatalf("unexpected empty diff message: %+v", messages[1]) + } + if messages[1].Output != "" { + t.Fatalf("expected empty diff output, got %q", messages[1].Output) + } +} + func TestCodexRawItemFileChangeUsesAggregatedOutputFallback(t *testing.T) { t.Parallel() From 3c4dcf8e3e69cd63a38ac965c47ebdc66d9580b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=BA?= Date: Fri, 22 May 2026 23:07:35 -0400 Subject: [PATCH 18/18] fix(agent): buffer Codex turn diffs and flush on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex sends multiple turn/diff/updated notifications per turn as the agent edits and revises files. Emitting each snapshot as a patch_apply result left stale rows on the append-only timeline for edit-then-revert sequences. Buffer the latest diff and flush only when the turn finishes via turn/completed, thread/status idle, final_answer agentMessage, or an abnormal exit (timeout/cancel/abort) — flushing after readerDone so a late diff still in the stdout pipe is not lost. On final_answer, flush before the text emit to preserve chronological transcript order. Co-Authored-By: Claude Opus 4.7 --- server/pkg/agent/codex.go | 60 ++++++++-- server/pkg/agent/codex_test.go | 202 ++++++++++++++++++++++++++++++--- 2 files changed, 239 insertions(+), 23 deletions(-) diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index dba8a041ae..8c313f154a 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -288,6 +288,14 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti // Wait for the reader goroutine to finish so all output is accumulated. <-readerDone + // Flush any buffered turn-level diff so abnormal exit paths (timeout, + // cancellation, abort) still record the latest snapshot. Run AFTER + // readerDone so any turn/diff/updated still in the stdout pipe at the + // moment the wait loop exited is buffered first. Normal turn endings + // already flushed during turn/completed, thread/status idle, or + // final_answer; a second call here is a safe no-op for them. + c.flushTurnDiff(c.turnID) + outputMu.Lock() finalOutput := output.String() outputMu.Unlock() @@ -835,6 +843,10 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any c.extractUsageFromMap(turn) } + // Flush any buffered turn-level diff before signaling done so the + // final aggregate diff lands on the timeline as a single entry. + c.flushTurnDiff(turnID) + if c.onTurnDone != nil { c.onTurnDone(aborted) } @@ -858,6 +870,7 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any case "thread/status/changed": statusType := extractNestedString(params, "status", "type") if statusType == "idle" && c.turnStarted { + c.flushTurnDiff(c.turnID) if c.onTurnDone != nil { c.onTurnDone(false) } @@ -938,14 +951,19 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an case method == "item/completed" && itemType == "agentMessage": text, _ := item["text"].(string) + phase, _ := item["phase"].(string) + isFinalAnswer := phase == "final_answer" && c.turnStarted + if isFinalAnswer { + // Flush the buffered diff before emitting the final-answer text: + // turn/diff/updated arrived earlier, so the transcript must show + // the patch_apply row above the final-answer message. + c.flushTurnDiff(c.turnID) + } if text != "" && c.onMessage != nil { c.onMessage(Message{Type: MessageText, Content: text}) } - phase, _ := item["phase"].(string) - if phase == "final_answer" && c.turnStarted { - if c.onTurnDone != nil { - c.onTurnDone(false) - } + if isFinalAnswer && c.onTurnDone != nil { + c.onTurnDone(false) } } } @@ -1011,6 +1029,13 @@ func (c *codexClient) popFileChangeDelta(itemID string) string { return output } +// emitTurnDiffUpdated buffers the latest aggregate diff for a turn. The diff +// is held until the turn finishes (via turn/completed, thread/status idle, +// final_answer agentMessage, or an abnormal exit such as timeout/cancel) and +// is emitted by flushTurnDiff. Codex can send several turn/diff/updated +// notifications per turn as the agent edits and revises files; appending +// each snapshot to the append-only task timeline would leave stale rows for +// edit-then-revert sequences. Buffering emits only the final state. func (c *codexClient) emitTurnDiffUpdated(turnID, diff string) { key := turnID if key == "" { @@ -1021,12 +1046,29 @@ func (c *codexClient) emitTurnDiffUpdated(turnID, diff string) { if c.lastTurnDiffs == nil { c.lastTurnDiffs = make(map[string]string) } - if c.lastTurnDiffs[key] == diff { - c.fileChangeDeltaMu.Unlock() - return - } c.lastTurnDiffs[key] = diff c.fileChangeDeltaMu.Unlock() +} + +// flushTurnDiff emits the buffered diff for a completed turn, if any. Empty +// diffs (turns that end with no net file changes) are dropped so no stale +// patch_apply row appears on the timeline. +func (c *codexClient) flushTurnDiff(turnID string) { + key := turnID + if key == "" { + key = "_unknown" + } + + c.fileChangeDeltaMu.Lock() + diff, ok := c.lastTurnDiffs[key] + if ok { + delete(c.lastTurnDiffs, key) + } + c.fileChangeDeltaMu.Unlock() + + if !ok || diff == "" { + return + } if c.onMessage != nil { c.onMessage(Message{ diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index b8a22b9c50..fe189ba486 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -545,7 +545,7 @@ func TestCodexRawItemFileChangeAggregatesOutputDelta(t *testing.T) { } } -func TestCodexRawTurnDiffUpdatedEmitsPatchResult(t *testing.T) { +func TestCodexRawTurnDiffEmitsFinalDiffOnTurnCompleted(t *testing.T) { t.Parallel() c, _, _ := newTestCodexClient(t) @@ -562,8 +562,14 @@ func TestCodexRawTurnDiffUpdatedEmitsPatchResult(t *testing.T) { c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-other","turnId":"turn-2","diff":"--- a/b.txt\n+++ b/b.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + if len(messages) != 0 { + t.Fatalf("expected no diff messages before turn/completed, got %d: %+v", len(messages), messages) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + if len(messages) != 1 { - t.Fatalf("expected one deduplicated patch diff message, got %d: %+v", len(messages), messages) + t.Fatalf("expected one buffered patch diff message, got %d: %+v", len(messages), messages) } if messages[0].Type != MessageToolResult || messages[0].Tool != "patch_apply" || messages[0].CallID != "turn-1:diff" { t.Fatalf("unexpected diff message: %+v", messages[0]) @@ -573,7 +579,7 @@ func TestCodexRawTurnDiffUpdatedEmitsPatchResult(t *testing.T) { } } -func TestCodexRawTurnDiffUpdatedEmitsEmptySnapshotAfterNonEmpty(t *testing.T) { +func TestCodexRawTurnDiffSuppressesTransientSnapshots(t *testing.T) { t.Parallel() c, _, _ := newTestCodexClient(t) @@ -586,22 +592,115 @@ func TestCodexRawTurnDiffUpdatedEmitsEmptySnapshotAfterNonEmpty(t *testing.T) { messages = append(messages, msg) } - c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + // Edit, then revise to a different diff. Only the latest should survive. + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+intermediate\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+final\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + + if len(messages) != 1 { + t.Fatalf("expected only the final diff to be emitted, got %d: %+v", len(messages), messages) + } + if messages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+final\n" { + t.Fatalf("expected final diff, got %q", messages[0].Output) + } +} + +func TestCodexRawTurnDiffFlushesOnFinalAnswer(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + c.turnStarted = true + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) - c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) - c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + // Turn finishes via final_answer agentMessage instead of turn/completed. + c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-main","item":{"type":"agentMessage","id":"msg-1","text":"Done","phase":"final_answer"}}}`) - if len(messages) != 2 { - t.Fatalf("expected non-empty diff and one empty clearing snapshot, got %d: %+v", len(messages), messages) + var diffIdx, textIdx int = -1, -1 + for i, m := range messages { + if m.Tool == "patch_apply" && m.CallID == "turn-1:diff" { + diffIdx = i + } + if m.Type == MessageText && m.Content == "Done" { + textIdx = i + } } - if messages[0].Output == "" { - t.Fatalf("expected first emitted diff to be non-empty: %+v", messages[0]) + if diffIdx < 0 { + t.Fatalf("expected buffered diff to flush on final_answer, got messages: %+v", messages) } - if messages[1].Type != MessageToolResult || messages[1].Tool != "patch_apply" || messages[1].CallID != "turn-1:diff" { - t.Fatalf("unexpected empty diff message: %+v", messages[1]) + if textIdx < 0 { + t.Fatalf("expected final-answer text to be emitted, got messages: %+v", messages) + } + // turn/diff/updated arrived before the final-answer text, so the + // transcript ordering must preserve that: diff first, text second. + if diffIdx > textIdx { + t.Fatalf("expected diff (idx=%d) to be emitted before final-answer text (idx=%d): %+v", diffIdx, textIdx, messages) + } + if messages[diffIdx].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", messages[diffIdx].Output) + } +} + +func TestCodexRawTurnDiffFlushesOnThreadIdle(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + c.turnStarted = true + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) + } + + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + // Turn finishes via thread/status idle instead of turn/completed. + c.handleLine(`{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"thr-main","status":{"type":"idle"}}}`) + + var diffMessages []Message + for _, m := range messages { + if m.Tool == "patch_apply" && m.CallID == "turn-1:diff" { + diffMessages = append(diffMessages, m) + } + } + if len(diffMessages) != 1 { + t.Fatalf("expected buffered diff to flush on thread/status idle, got %d: %+v", len(diffMessages), diffMessages) + } + if diffMessages[0].Output != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected diff output: %q", diffMessages[0].Output) + } +} + +func TestCodexRawTurnDiffSkipsEmptyFinalSnapshot(t *testing.T) { + t.Parallel() + + c, _, _ := newTestCodexClient(t) + c.notificationProtocol = "raw" + c.threadID = "thr-main" + c.turnID = "turn-1" + + var messages []Message + c.onMessage = func(msg Message) { + messages = append(messages, msg) } - if messages[1].Output != "" { - t.Fatalf("expected empty diff output, got %q", messages[1].Output) + + // Edit, then revert: turn ends with no net change. No patch_apply entry + // should be appended to the append-only timeline. + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-main","turnId":"turn-1","diff":""}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-main","turn":{"id":"turn-1","status":"completed"}}}`) + + if len(messages) != 0 { + t.Fatalf("expected no diff messages when final snapshot is empty, got %d: %+v", len(messages), messages) } } @@ -1318,6 +1417,81 @@ func TestCodexExecuteSemanticInactivityDoesNotAffectNormalTurnCompletion(t *test } } +func TestCodexExecuteFlushesBufferedDiffOnSemanticInactivityTimeout(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + // Fake codex emits turn/started + turn/diff/updated and then hangs. + // Semantic inactivity timeout will fire before turn/completed arrives. + // Use printf instead of echo so the JSON \n escapes are emitted as + // literal "\n" bytes (not interpreted as newlines by the shell). + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-hang"}}}'`+"\n"+ + `read line`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-hang","turn":{"id":"turn-hang"}}}'`+"\n"+ + `sleep 0.1`+"\n"+ + `printf '%s\n' '{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"thr-hang","turnId":"turn-hang","diff":"--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n"}}'`+"\n"+ + // Stay alive long enough for semantic inactivity timeout to fire after + // the diff is processed. + `sleep 5`+"\n") + + backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new codex backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := backend.Execute(ctx, "prompt", ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 1500 * time.Millisecond, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var diffSeen bool + var diffOutput string + var allMessages []Message + done := make(chan struct{}) + go func() { + defer close(done) + for msg := range session.Messages { + allMessages = append(allMessages, msg) + if msg.Tool == "patch_apply" && msg.CallID == "turn-hang:diff" { + diffSeen = true + diffOutput = msg.Output + } + } + }() + + select { + case result, ok := <-session.Result: + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "timeout" { + t.Fatalf("expected status=timeout, got %q (error=%q)", result.Status, result.Error) + } + case <-time.After(8 * time.Second): + t.Fatal("timeout waiting for result") + } + <-done + + if !diffSeen { + t.Fatalf("expected buffered diff to flush on semantic inactivity timeout, got messages: %+v", allMessages) + } + if diffOutput != "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" { + t.Fatalf("unexpected flushed diff: %q", diffOutput) + } +} + func writeFakeCodexAppServer(t *testing.T, body string) string { t.Helper() fakePath := filepath.Join(t.TempDir(), "codex")