diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e234900..a9c5125 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(node --check src/native-agent.js)", - "Bash(npm test *)" + "Bash(npm test *)", + "Bash(xargs cat)" ] } } diff --git a/evcod/core/internal/api/router.go b/evcod/core/internal/api/router.go index c1c24e3..d816fce 100644 --- a/evcod/core/internal/api/router.go +++ b/evcod/core/internal/api/router.go @@ -1296,6 +1296,9 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) { AgentType string `json:"agentType"` Model string `json:"model"` PermissionMode string `json:"permissionMode"` + Effort string `json:"effort"` + FastMode *bool `json:"fastMode"` + PlanMode *bool `json:"planMode"` } if !decode(w, req, &body) { return @@ -1314,6 +1317,13 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) { writeResult(w, nil, err) return } + // Persist the chosen model/permission mode on the session (without firing a + // model-change inject — the agent picks these up via its launch args). + if strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil { + if updated, cfgErr := r.services.Agent.SetConfig(session.ID, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, "launch"); cfgErr == nil { + session = updated + } + } args := []string{ "evcod-warp", session.AgentType, @@ -1374,11 +1384,20 @@ func (r *Router) agentSessionByID(w http.ResponseWriter, req *http.Request) { Status string `json:"status"` StatusReason string `json:"statusReason"` ProviderSessionID string `json:"providerSessionId"` + Model string `json:"model"` + PermissionMode string `json:"permissionMode"` + Effort string `json:"effort"` + FastMode *bool `json:"fastMode"` + PlanMode *bool `json:"planMode"` + ConfigSource string `json:"configSource"` } if !decode(w, req, &body) { return } session, err := r.services.Agent.Update(id, body.PaneID, body.AgentType, body.Status, body.StatusReason, body.ProviderSessionID) + if err == nil && (strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil) { + session, err = r.services.Agent.SetConfig(id, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, body.ConfigSource) + } writeResult(w, session, err) case req.Method == http.MethodPost && action == "lock": var body struct { diff --git a/evcod/core/internal/domain/models.go b/evcod/core/internal/domain/models.go index e8effa1..df2a49a 100644 --- a/evcod/core/internal/domain/models.go +++ b/evcod/core/internal/domain/models.go @@ -174,6 +174,11 @@ type AgentSession struct { ConversationID string `json:"conversationId"` PaneID *string `json:"paneId,omitempty"` AgentType string `json:"agentType"` + Model string `json:"model,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Effort string `json:"effort,omitempty"` + FastMode bool `json:"fastMode,omitempty"` + PlanMode bool `json:"planMode,omitempty"` Status string `json:"status"` StatusReason string `json:"statusReason,omitempty"` ProviderSessionID string `json:"providerSessionId,omitempty"` diff --git a/evcod/core/internal/services/host.go b/evcod/core/internal/services/host.go index 051bb5a..774a9c7 100644 --- a/evcod/core/internal/services/host.go +++ b/evcod/core/internal/services/host.go @@ -994,6 +994,25 @@ func parseWindowsNetstat(output, protocol string, processNames map[int]string) [ } func unixPorts(processNames map[int]string) ([]domain.HostPort, error) { + if goruntime.GOOS == "darwin" { + ports, err := lsofPorts(processNames) + if err != nil { + return []domain.HostPort{}, nil + } + return ports, nil + } + ports, err := ssPorts(processNames) + if err == nil { + return ports, nil + } + ports, err = lsofPorts(processNames) + if err != nil { + return []domain.HostPort{}, nil + } + return ports, nil +} + +func ssPorts(processNames map[int]string) ([]domain.HostPort, error) { out, err := runHostCommand(3500*time.Millisecond, "ss", "-tunlp") if err != nil { return nil, err @@ -1025,6 +1044,53 @@ func unixPorts(processNames map[int]string) ([]domain.HostPort, error) { return ports, nil } +func lsofPorts(processNames map[int]string) ([]domain.HostPort, error) { + out, err := runHostCommand(3500*time.Millisecond, "lsof", "-nP", "-iTCP", "-iUDP") + if err != nil { + return nil, err + } + return parseLsofPorts(out, processNames), nil +} + +func parseLsofPorts(output string, processNames map[int]string) []domain.HostPort { + ports := []domain.HostPort{} + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 9 || fields[0] == "COMMAND" { + continue + } + protocol := strings.ToUpper(fields[7]) + if protocol != "TCP" && protocol != "UDP" { + continue + } + nameField := strings.Join(fields[8:], " ") + state := "" + if idx := strings.LastIndex(nameField, " ("); idx >= 0 && strings.HasSuffix(nameField, ")") { + state = strings.TrimSuffix(strings.TrimPrefix(nameField[idx+1:], "("), ")") + nameField = strings.TrimSpace(nameField[:idx]) + } + local := strings.TrimSpace(strings.SplitN(nameField, "->", 2)[0]) + localAddress, localPort := splitAddressPort(local) + if localPort == "" || localPort == "*" { + continue + } + pid, _ := strconv.Atoi(fields[1]) + processName := processNames[pid] + if processName == "" { + processName = fields[0] + } + ports = append(ports, domain.HostPort{ + Protocol: protocol, + LocalAddress: localAddress, + LocalPort: localPort, + State: state, + PID: pid, + ProcessName: processName, + }) + } + return ports +} + func parseSSProcess(value string) (int, string) { name := "" pid := 0 diff --git a/evcod/core/internal/services/host_ports_test.go b/evcod/core/internal/services/host_ports_test.go new file mode 100644 index 0000000..ba2823a --- /dev/null +++ b/evcod/core/internal/services/host_ports_test.go @@ -0,0 +1,29 @@ +package services + +import "testing" + +func TestParseLsofPorts(t *testing.T) { + output := `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +rapportd 494 fu 6u IPv4 0x1 0t0 TCP *:50847 (LISTEN) +evcod 1234 fu 20u IPv4 0x2 0t0 TCP 127.0.0.1:10065 (LISTEN) +identity 503 fu 10u IPv4 0x3 0t0 UDP *:* +sharing 517 fu 4u IPv4 0x4 0t0 UDP *:5353 +remote 600 fu 9u IPv6 0x5 0t0 TCP [fe80::1]:50847->[fe80::2]:52609 (ESTABLISHED) +` + ports := parseLsofPorts(output, map[int]string{1234: "evcod-core"}) + if len(ports) != 4 { + t.Fatalf("unexpected port count: got %d %#v", len(ports), ports) + } + if ports[0].Protocol != "TCP" || ports[0].LocalAddress != "*" || ports[0].LocalPort != "50847" || ports[0].State != "LISTEN" || ports[0].PID != 494 || ports[0].ProcessName != "rapportd" { + t.Fatalf("unexpected first port: %#v", ports[0]) + } + if ports[1].ProcessName != "evcod-core" || ports[1].LocalAddress != "127.0.0.1" || ports[1].LocalPort != "10065" { + t.Fatalf("unexpected process-name fallback: %#v", ports[1]) + } + if ports[2].Protocol != "UDP" || ports[2].LocalPort != "5353" { + t.Fatalf("unexpected udp port: %#v", ports[2]) + } + if ports[3].State != "ESTABLISHED" || ports[3].LocalAddress != "fe80::1" || ports[3].LocalPort != "50847" { + t.Fatalf("unexpected established ipv6 port: %#v", ports[3]) + } +} diff --git a/evcod/core/internal/services/services.go b/evcod/core/internal/services/services.go index b9055f3..dd11b8e 100644 --- a/evcod/core/internal/services/services.go +++ b/evcod/core/internal/services/services.go @@ -2193,6 +2193,87 @@ func (s *AgentService) Update(id, paneID, agentType, status, statusReason, provi return session, nil } +// SetConfig persists the selected model and turn controls on a session and +// announces the change so the running agent (warp) can sync it into the native +// CLI. Empty string values and nil booleans leave the corresponding field +// untouched. `source` records where the change came from ("web" or "terminal") +// so consumers can avoid echo loops. +func (s *AgentService) SetConfig(id, model, permissionMode, effort string, fastMode, planMode *bool, source string) (domain.AgentSession, error) { + model = strings.TrimSpace(model) + permissionMode = strings.TrimSpace(permissionMode) + effort = strings.TrimSpace(effort) + if source == "" { + source = "web" + } + modelChanged := false + configChanged := false + session, err := s.store.UpdateAgentSession(id, func(session *domain.AgentSession) error { + if model != "" && model != session.Model { + session.Model = model + modelChanged = true + configChanged = true + } + if permissionMode != "" && permissionMode != session.PermissionMode { + session.PermissionMode = permissionMode + configChanged = true + } + if effort != "" && effort != session.Effort { + session.Effort = effort + configChanged = true + } + if fastMode != nil && *fastMode != session.FastMode { + session.FastMode = *fastMode + configChanged = true + } + if planMode != nil && *planMode != session.PlanMode { + session.PlanMode = *planMode + configChanged = true + } + session.UpdatedAt = store.Now() + return nil + }) + if err != nil { + return domain.AgentSession{}, err + } + s.publishSession("agent.session.updated", session) + if configChanged || modelChanged { + paneID := "" + if session.PaneID != nil { + paneID = *session.PaneID + } + payload := map[string]any{ + "sessionId": session.ID, + "conversationId": session.ConversationID, + "paneId": paneID, + "agentType": session.AgentType, + "model": session.Model, + "permissionMode": session.PermissionMode, + "effort": session.Effort, + "fastMode": session.FastMode, + "planMode": session.PlanMode, + "source": source, + "timestamp": store.Now(), + } + s.events.Publish("agent.config.changed", payload) + } + if modelChanged { + paneID := "" + if session.PaneID != nil { + paneID = *session.PaneID + } + s.events.Publish("agent.model.changed", map[string]any{ + "sessionId": session.ID, + "conversationId": session.ConversationID, + "paneId": paneID, + "agentType": session.AgentType, + "model": session.Model, + "source": source, + "timestamp": store.Now(), + }) + } + return session, nil +} + func (s *AgentService) AcquireLock(id, owner, turnID string, expectedVersion *int64, providerSessionID string) (domain.AgentSession, error) { if strings.TrimSpace(owner) == "" { return domain.AgentSession{}, errors.New("lock owner is required") diff --git a/evcod/dev.command b/evcod/dev.command old mode 100644 new mode 100755 diff --git a/evcod/webui/src/api.ts b/evcod/webui/src/api.ts index f1cc1af..41918d0 100644 --- a/evcod/webui/src/api.ts +++ b/evcod/webui/src/api.ts @@ -507,14 +507,19 @@ export class CoreApi { }); } - launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string }) { + launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string; effort?: string; fastMode?: boolean; planMode?: boolean }) { return this.request<{ session: AgentSession; command: string }>('/api/agent/launch', { method: 'POST', body: JSON.stringify(input), }); } - updateAgentSession(id: string, patch: Partial>) { + updateAgentSession( + id: string, + patch: Partial> & { + configSource?: 'web' | 'terminal' | 'launch'; + }, + ) { return this.request(`/api/agent/sessions/${id}`, { method: 'PUT', body: JSON.stringify(patch), diff --git a/evcod/webui/src/components/ConversationView.tsx b/evcod/webui/src/components/ConversationView.tsx index 1abff61..97308e8 100644 --- a/evcod/webui/src/components/ConversationView.tsx +++ b/evcod/webui/src/components/ConversationView.tsx @@ -206,12 +206,23 @@ export function ConversationView(props: { useEffect(() => { if (session?.agentType) { setAgentType(session.agentType); - setModel(defaultModelForAgent(session.agentType)); + // Prefer the model persisted on the session so the selector reflects the + // live agent (and stays in sync after a model change) instead of resetting + // to the agent default on every session update. + setModel(session.model?.trim() ? session.model : defaultModelForAgent(session.agentType)); + setPermissionMode(session.permissionMode?.trim() ? session.permissionMode : 'full-access'); + setEffort(session.effort?.trim() ? session.effort : 'extra-high'); + setFastMode(Boolean(session.fastMode)); + setPlanMode(Boolean(session.planMode)); return; } const nextAgent = activeCore?.defaultAgentType ?? frontendSettings.defaultAgentType; setAgentType(nextAgent); setModel(defaultModelForAgent(nextAgent)); + setPermissionMode('full-access'); + setEffort('extra-high'); + setFastMode(false); + setPlanMode(false); }, [activeCore?.defaultAgentType, activeCore?.defaultOpenCodeModel, frontendSettings.defaultAgentType, frontendSettings.defaultOpenCodeModel, session]); useEffect(() => { @@ -338,6 +349,9 @@ export function ConversationView(props: { agentType, model: model.trim() ? model : undefined, permissionMode, + effort, + fastMode, + planMode, }); const next = launched.session; await catchUpTimeline(conversationId); @@ -558,6 +572,44 @@ export function ConversationView(props: { setModel(defaultModelForAgent(nextAgent)); } + function handleModelChange(nextModel: string) { + setModel(nextModel); + // When a session is already running, push the change so the core records it + // and warp types the model switch into the native CLI (two-way model sync). + const live = session?.id && session.status !== 'closed' && session.status !== 'unbound'; + if (props.api && live && nextModel.trim() && nextModel !== session?.model) { + props.api + .updateAgentSession(session!.id, { model: nextModel, configSource: 'web' }) + .catch((error) => toastError(error, 'Could not update model')); + } + } + + function updateSessionControls(patch: Partial>) { + const live = session?.id && session.status !== 'closed' && session.status !== 'unbound'; + if (!props.api || !live) return; + props.api.updateAgentSession(session!.id, { ...patch, configSource: 'web' }).catch((error) => toastError(error, 'Could not update controls')); + } + + function handlePermissionModeChange(nextPermissionMode: string) { + setPermissionMode(nextPermissionMode); + if (nextPermissionMode !== session?.permissionMode) updateSessionControls({ permissionMode: nextPermissionMode }); + } + + function handleEffortChange(nextEffort: string) { + setEffort(nextEffort); + if (nextEffort !== session?.effort) updateSessionControls({ effort: nextEffort }); + } + + function handleFastModeChange(nextFastMode: boolean) { + setFastMode(nextFastMode); + if (nextFastMode !== Boolean(session?.fastMode)) updateSessionControls({ fastMode: nextFastMode }); + } + + function handlePlanModeChange(nextPlanMode: boolean) { + setPlanMode(nextPlanMode); + if (nextPlanMode !== Boolean(session?.planMode)) updateSessionControls({ planMode: nextPlanMode }); + } + return (
@@ -617,15 +669,15 @@ export function ConversationView(props: { agentOptions={agentOptions} modelOptions={modelOptions} onAgentTypeChange={changeAgentType} - onModelChange={setModel} + onModelChange={handleModelChange} effort={effort} - onEffortChange={setEffort} + onEffortChange={handleEffortChange} permissionMode={permissionMode} - onPermissionModeChange={setPermissionMode} + onPermissionModeChange={handlePermissionModeChange} fastMode={fastMode} - onFastModeChange={setFastMode} + onFastModeChange={handleFastModeChange} planMode={planMode} - onPlanModeChange={setPlanMode} + onPlanModeChange={handlePlanModeChange} conversationSummary={conversationSummary} contextSummary={contextSummary} usageSummary={usageSummary} diff --git a/evcod/webui/src/components/DirectoryPicker.tsx b/evcod/webui/src/components/DirectoryPicker.tsx index 3122862..3de20a5 100644 --- a/evcod/webui/src/components/DirectoryPicker.tsx +++ b/evcod/webui/src/components/DirectoryPicker.tsx @@ -102,7 +102,7 @@ export function DirectoryPicker(props: {
{crumbs.length === 0 ? Default locations : null} {crumbs.map((crumb, index) => ( - + {index < crumbs.length - 1 ? / : null} @@ -149,8 +149,8 @@ export function DirectoryPicker(props: { No sub-folders here.
) : null} - {listing?.entries.map((entry) => ( - ) : null} - {(listing?.entries ?? []).map((entry) => ( - entry.isDir && void load(entry.path)} /> + {(listing?.entries ?? []).map((entry, index) => ( + entry.isDir && void load(entry.path)} /> ))}
diff --git a/evcod/webui/src/types.ts b/evcod/webui/src/types.ts index 23ccaaa..5fc2841 100644 --- a/evcod/webui/src/types.ts +++ b/evcod/webui/src/types.ts @@ -163,6 +163,11 @@ export type AgentSession = { conversationId: string; paneId?: string; agentType: string; + model?: string; + permissionMode?: string; + effort?: string; + fastMode?: boolean; + planMode?: boolean; status: string; statusReason?: string; providerSessionId?: string; diff --git a/evcod_warp/src/native-agent.js b/evcod_warp/src/native-agent.js index 749db74..5e93532 100644 --- a/evcod_warp/src/native-agent.js +++ b/evcod_warp/src/native-agent.js @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { basename, extname, join, normalize, resolve } from 'node:path'; +import { basename, extname, join, normalize, resolve, sep } from 'node:path'; import { homedir, platform } from 'node:os'; import { normalizeClaudeTranscriptLine, @@ -41,7 +41,11 @@ export class NativeAgentController { this.projectPath = projectPath; this.projectId = projectId; this.model = model; + this.lastSyncedModel = String(model ?? '').trim() || undefined; this.permissionMode = normalizePermissionMode(permissionMode); + this.effort = undefined; + this.fastMode = false; + this.planMode = false; this.passthroughArgs = Array.isArray(passthroughArgs) ? passthroughArgs : []; this.homeDir = homeDir; this.env = env; @@ -55,6 +59,7 @@ export class NativeAgentController { this.turnCompletionWaiters = new Map(); this.implicitCompletionTimers = new Map(); this.emitted = new Map(); + this.ownershipCache = new Map(); this.fallbackMessageSeq = 0; this.transcriptEpoch = 0; this.lastKnownSize = 0; @@ -208,6 +213,14 @@ export class NativeAgentController { } else if (event.event === 'agent.cancel.requested') { if (this.paneId) await this.core.inputPane(this.paneId, '\x03').catch(() => undefined); await this.#completeActiveTurn('canceled').catch(() => undefined); + } else if (event.event === 'agent.config.changed') { + await this.#applySessionConfigEvent(event.payload); + } else if (event.event === 'agent.model.changed') { + // The web UI changed the model on a running session: type the provider's + // model-switch command into the native TUI so both sides stay in sync. + // Only act on web-originated changes (source "launch"/"terminal" are + // already reflected by the CLI and must not be re-injected). + await this.#applySessionConfigEvent(event.payload, { modelOnly: true }); } } @@ -269,6 +282,22 @@ export class NativeAgentController { await this.#rediscoverTranscript(); return; } + let grew = true; + try { + grew = statSync(this.transcriptPath).size !== this.lastKnownSize; + } catch { + grew = true; + } + await this.#syncCurrentTranscript(); + // When the current transcript is idle, the agent may have rotated to a new + // file (claude `/clear`, a fresh codex rollout). Follow that file so messages + // produced after the rotation are not lost. + if (!grew && this.#followRotation()) { + await this.#syncCurrentTranscript(); + } + } + + async #syncCurrentTranscript() { if (this.agentConfig.transcriptFormat === 'json') { await this.#syncJsonTranscript(); } else { @@ -276,6 +305,51 @@ export class NativeAgentController { } } + // Switch to a newer transcript that belongs to this session. Only files we can + // attribute to this agent are eligible: those under a project-scoped search + // root, or (codex, whose sessions dir is global) whose session_meta cwd matches + // this project. Returns true when the active transcript was switched. + #followRotation() { + if (this.stopped || !this.agentConfig) return false; + let currentMtime = 0; + try { + currentMtime = statSync(this.transcriptPath).mtimeMs; + } catch { + currentMtime = 0; + } + let best; + for (const file of snapshotTranscriptFiles(this.agentConfig).values()) { + if (file.path === this.transcriptPath || file.mtimeMs <= currentMtime) continue; + if (!this.#isOwnedTranscript(file.path)) continue; + if (!best || file.mtimeMs > best.mtimeMs) best = file; + } + if (!best) return false; + this.transcriptPath = best.path; + this.lastKnownSize = 0; + this.lastMessageCount = 0; + this.partialLine = ''; + this.fallbackMessageSeq = 0; + this.transcriptEpoch++; + return true; + } + + #isOwnedTranscript(path) { + const cached = this.ownershipCache.get(path); + if (cached !== undefined) return cached; + let owned = false; + for (const root of this.agentConfig.searchRoots ?? []) { + if (root.scoped && isPathInside(path, root.dir)) { + owned = true; + break; + } + } + if (!owned && this.agentType === 'codex') { + owned = codexTranscriptCwd(path) === resolve(this.projectPath); + } + this.ownershipCache.set(path, owned); + return owned; + } + async #rediscoverTranscript() { if (!this.agentConfig || this.stopped) return; const transcript = await this.#discoverTranscript(this.discoveryBaseline ?? new Map(), 1); @@ -351,6 +425,10 @@ export class NativeAgentController { const partKey = messageKey(message, fallbackKey); if (this.emitted.has(partKey)) continue; this.emitted.set(partKey, true); + if (message.kind === 'session_config') { + await this.#applyCliSessionConfig(message.payload); + continue; + } if (message.role === 'user') { await this.#emitUserMessage(message); continue; @@ -361,6 +439,53 @@ export class NativeAgentController { } } + // Mirror a CLI-side config change (e.g. codex `/model` typed in the TUI) back + // to the core so the web UI's selector follows it. Tagged source "terminal" + // so the core does not echo it back as a model-switch keystroke. + async #applyCliSessionConfig(config) { + const model = String(config?.model ?? '').trim(); + const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode); + const effort = String(config?.effort ?? '').trim(); + const patch = { configSource: 'terminal' }; + if (model && model !== this.lastSyncedModel) { + this.lastSyncedModel = model; + patch.model = model; + } + if (permissionMode && permissionMode !== this.permissionMode) { + this.permissionMode = permissionMode; + patch.permissionMode = permissionMode; + } + if (effort && effort !== this.effort) { + this.effort = effort; + patch.effort = effort; + } + if (Object.keys(patch).length === 1 || !this.session?.id) return; + if (typeof this.core.updateSession !== 'function') return; + this.session = await this.core.updateSession(this.session.id, patch).catch(() => this.session); + } + + async #applySessionConfigEvent(config, { modelOnly = false } = {}) { + const source = String(config?.source ?? 'web'); + const model = String(config?.model ?? '').trim(); + const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode); + const effort = String(config?.effort ?? '').trim(); + const shouldSwitchModel = source === 'web' && model && model !== this.lastSyncedModel; + if (model) { + this.model = model; + this.lastSyncedModel = model; + } + if (!modelOnly) { + if (permissionMode) this.permissionMode = permissionMode; + if (effort) this.effort = effort; + if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode; + if (typeof config?.planMode === 'boolean') this.planMode = config.planMode; + } + if (shouldSwitchModel && this.paneId) { + const input = nativeModelSwitchInput(this.agentType, model); + if (input) await this.core.inputPane(this.paneId, input).catch(() => undefined); + } + } + async #emitUserMessage(message) { const content = String(message.content ?? ''); const normalized = normalizePrompt(content); @@ -538,7 +663,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_CLAUDE_BIN ?? env.CLAUDE_BIN ?? 'claude', args: [...permissionArgs, ...modelArgs, '--add-dir', projectPath, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: false }], + searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeClaudeTranscriptLine, @@ -554,7 +679,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_CODEX_BIN ?? env.CODEX_BIN ?? 'codex', args: [...modelArgs, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: true }], + searchRoots: [{ dir: transcriptDir, recursive: true, scoped: false }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeCodexTranscriptLine, @@ -572,8 +697,8 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en args: [...modelArgs, ...extraArgs], ensureDirs: [projectChatDir], searchRoots: [ - { dir: projectChatDir, recursive: false }, - { dir: join(geminiRoot, 'tmp'), recursive: true }, + { dir: projectChatDir, recursive: false, scoped: true }, + { dir: join(geminiRoot, 'tmp'), recursive: true, scoped: false }, ], extensions: ['.json'], transcriptFormat: 'json', @@ -589,7 +714,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_QWEN_BIN ?? env.QWEN_BIN ?? 'qwen', args: [...modelArgs, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: false }], + searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeQwenTranscriptLine, @@ -603,6 +728,19 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en } } +// Keystrokes that switch the active model inside a native agent TUI. Best-effort +// and consistent with how prompts/permissions are injected: claude/codex/gemini/ +// qwen all expose a `/model ` slash command in their interactive UI. +export function nativeModelSwitchInput(agentType, model) { + const trimmed = String(model ?? '').trim(); + if (!trimmed) return ''; + const normalized = String(agentType ?? '').toLowerCase(); + if (['claude', 'codex', 'gemini', 'qwen'].includes(normalized)) { + return terminalSubmit(`/model ${trimmed}`); + } + return ''; +} + function nativeModelArgs(agentType, model) { const trimmed = String(model ?? '').trim(); if (!trimmed) return []; @@ -618,7 +756,38 @@ function nativeModelArgs(agentType, model) { } export function encodeProjectPath(path) { - return resolve(path).replace(/[^a-zA-Z0-9]/g, '-'); + const raw = String(path ?? ''); + const absolute = /^[a-zA-Z]:[\\/]/.test(raw) ? raw : resolve(raw); + return absolute.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function isPathInside(path, dir) { + const base = resolve(dir); + const target = resolve(path); + return target === base || target.startsWith(base + sep); +} + +// Read the cwd a codex rollout was started in (recorded in its session_meta +// record) so a globally-stored rollout can be attributed to a project. +function codexTranscriptCwd(path) { + try { + for (const raw of readFileSync(path, 'utf8').split(/\r?\n/)) { + if (!raw.trim()) continue; + let record; + try { + record = JSON.parse(raw); + } catch { + continue; + } + const cwd = record?.payload?.cwd ?? record?.cwd; + if (record?.type === 'session_meta' || cwd) { + return cwd ? resolve(String(cwd)) : ''; + } + } + } catch { + // Unreadable file: treat as unattributable. + } + return ''; } function snapshotTranscriptFiles(config) { @@ -706,8 +875,19 @@ function agentPromptFromMessage(message, payload) { return String(payload?.agentContent ?? message?.payload?.agentContent ?? message?.content ?? ''); } +// Inject a chat prompt into a native agent's TUI and submit it. A bare CR +// submits the current input, so a multi-line prompt sent as "a\rb\r" would be +// submitted line-by-line (splitting one prompt into several and breaking the +// web-echo dedup). Modern agent TUIs (claude/codex/gemini/qwen) enable +// bracketed paste, so wrap multi-line text in paste markers — newlines inside a +// paste are treated as literal newlines — then send a single trailing CR to +// submit the whole prompt at once. Single-line prompts keep the simple path. function terminalSubmit(value) { - return `${String(value ?? '').replace(/\r?\n/g, '\r')}\r`; + const text = String(value ?? ''); + if (/[\r\n]/.test(text)) { + return `\x1b[200~${text.replace(/\r\n/g, '\n')}\x1b[201~\r`; + } + return `${text}\r`; } function nativePermissionResponseInput(agentType, { response, allow, permission = {} } = {}) { diff --git a/evcod_warp/src/normalizer.js b/evcod_warp/src/normalizer.js index d226383..8d6d869 100644 --- a/evcod_warp/src/normalizer.js +++ b/evcod_warp/src/normalizer.js @@ -157,6 +157,20 @@ export function normalizeCodexTranscriptLine(line) { const type = payload.type; const special = normalizeSpecialMessage(payload, `codex:${type ?? line.type ?? 'event'}:${payload.id ?? line.timestamp ?? ''}`); if (special) return [special]; + // Codex records the active model (and approval/sandbox) per turn in a + // turn_context record. Surface it as a session_config signal so the controller + // can mirror a CLI-side model switch back to the web UI (CLI -> UI sync). + if (line.type === 'turn_context') { + const model = String(payload.model ?? payload.collaboration_mode?.settings?.model ?? '').trim(); + const permissionMode = codexPermissionMode(payload.approval_policy ?? payload.approvalPolicy ?? payload.collaboration_mode?.settings?.approval_policy); + const effort = String(payload.reasoning_effort ?? payload.reasoningEffort ?? payload.collaboration_mode?.settings?.reasoning_effort ?? '').trim(); + const config = {}; + if (model) config.model = model; + if (permissionMode) config.permissionMode = permissionMode; + if (effort) config.effort = effort; + if (Object.keys(config).length === 0) return []; + return [{ kind: 'session_config', status: 'completed', payload: config, key: `codex:turn_context:${payload.turn_id ?? line.timestamp ?? model ?? ''}` }]; + } if (line.type === 'response_item') { if (type === 'message') { const text = extractCodexText(payload.content); @@ -234,31 +248,15 @@ export function normalizeCodexTranscriptLine(line) { const usage = payload.info?.last_token_usage ?? payload.info?.total_token_usage ?? payload.info ?? payload; return [{ kind: 'usage', status: 'completed', payload: usage, key: `codex:usage:${line.timestamp ?? JSON.stringify(usage).slice(0, 80)}` }]; } - if (line.type === 'event_msg' && type === 'user_message') { - const content = codexUserMessageText(payload); - return content.trim() && !shouldIgnoreSyntheticCodexUserMessage(content) - ? [{ - role: 'user', - kind: 'text', - content, - status: 'completed', - payload, - key: `codex:user:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`, - }] - : []; - } - if (line.type === 'event_msg' && isCodexAssistantMessageType(type)) { - const content = extractCodexText(payload.message ?? payload.text ?? payload.content ?? payload.delta); - return content - ? [{ - role: 'assistant', - kind: 'text', - content, - status: 'completed', - payload, - key: `codex:${type}:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`, - }] - : []; + // Codex writes every user/assistant message to the rollout twice: once as a + // durable `response_item` (handled above) and once as an `event_msg` + // (`user_message` / `agent_message`) for live streaming. Mirroring both would + // duplicate every message in the web chat, so the durable `response_item` is + // the single source of truth for message text and these `event_msg` variants + // are ignored. Reasoning is the exception: `response_item` reasoning summaries + // are often empty/encrypted, so readable reasoning only arrives via `event_msg`. + if (line.type === 'event_msg' && (type === 'user_message' || isCodexAssistantMessageType(type))) { + return []; } if (line.type === 'event_msg' && isCodexReasoningType(type)) { const content = extractCodexText(payload.delta ?? payload.text ?? payload.message ?? payload.reasoning ?? payload.content); @@ -285,24 +283,21 @@ export function normalizeCodexTranscriptLine(line) { return []; } -function codexUserMessageText(payload) { - if (payload.message != null) return String(payload.message); - if (payload.text != null) return String(payload.text); - if (payload.content != null) return extractCodexText(payload.content); - if (Array.isArray(payload.text_elements)) { - return payload.text_elements - .map((part) => (typeof part === 'string' ? part : part.text ?? part.content ?? part.message ?? '')) - .filter(Boolean) - .join('\n'); - } - return ''; -} - function shouldIgnoreSyntheticCodexUserMessage(content) { const normalized = String(content ?? '').trim(); return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith(''); } +function codexPermissionMode(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return ''; + if (['never', 'on-request', 'on_request', 'ask', 'ask-first', 'ask_first'].includes(normalized)) return 'ask-first'; + if (['read-only', 'read_only', 'readonly'].includes(normalized)) return 'read-only'; + if (['on-failure', 'on_failure', 'untrusted'].includes(normalized)) return 'ask-first'; + if (['always', 'full-access', 'full_access', 'danger-full-access'].includes(normalized)) return 'full-access'; + return ''; +} + function isCodexAssistantMessageType(type) { return [ 'agent_message', diff --git a/evcod_warp/src/opencode-tui.js b/evcod_warp/src/opencode-tui.js index 2339ae5..2ce15f2 100644 --- a/evcod_warp/src/opencode-tui.js +++ b/evcod_warp/src/opencode-tui.js @@ -22,7 +22,12 @@ export class OpencodeTuiController { this.paneId = paneId; this.projectId = projectId; this.cwd = cwd; - this.model = model; + this.model = parseOpencodeModel(model) ?? model; + this.modelKey = opencodeModelKey(this.model); + this.permissionMode = undefined; + this.effort = undefined; + this.fastMode = false; + this.planMode = false; this.attach = attach; this.server = undefined; this.serverProc = undefined; @@ -118,6 +123,8 @@ export class OpencodeTuiController { if (message?.source === 'agent' || source === 'agent' || source === 'terminal') return; const turnId = String(event.payload.turnId ?? message?.turnId ?? ''); await this.enqueuePrompt(agentPromptFromMessage(message, event.payload), turnId, source); + } else if (event.event === 'agent.config.changed' || event.event === 'agent.model.changed') { + await this.#applySessionConfigEvent(event.payload, { modelOnly: event.event === 'agent.model.changed' }); } else if (event.event === 'agent.cancel.requested') { if (this.currentTurnId) this.cancelledTurns.add(this.currentTurnId); if (this.sessionId) await this.server.abort(this.sessionId); @@ -378,6 +385,28 @@ export class OpencodeTuiController { }); } + async #applySessionConfigEvent(config, { modelOnly = false } = {}) { + const source = String(config?.source ?? 'web'); + const model = parseOpencodeModel(config?.model); + const nextModelKey = opencodeModelKey(model); + const shouldSwitchModel = source === 'web' && model && nextModelKey !== this.modelKey; + if (model) { + this.model = model; + this.modelKey = nextModelKey; + } + if (!modelOnly) { + const permissionMode = String(config?.permissionMode ?? '').trim(); + const effort = String(config?.effort ?? '').trim(); + if (permissionMode) this.permissionMode = permissionMode; + if (effort) this.effort = effort; + if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode; + if (typeof config?.planMode === 'boolean') this.planMode = config.planMode; + } + if (shouldSwitchModel && this.paneId && typeof this.core.inputPane === 'function') { + await this.core.inputPane(this.paneId, terminalSubmit(`/model ${model.providerID}/${model.modelID}`)).catch(() => undefined); + } + } + async #completeActiveTurn(status) { if (!this.currentTurnId || this.completedTurns.has(this.currentTurnId)) return; const turnId = this.currentTurnId; @@ -426,6 +455,24 @@ function quote(value) { return `"${value.replace(/(["\\])/g, '\\$1')}"`; } +function parseOpencodeModel(value) { + if (value && typeof value === 'object' && value.providerID && value.modelID) { + return { providerID: String(value.providerID), modelID: String(value.modelID) }; + } + const text = String(value ?? '').trim(); + const slash = text.indexOf('/'); + if (slash <= 0 || slash === text.length - 1) return undefined; + return { providerID: text.slice(0, slash), modelID: text.slice(slash + 1) }; +} + +function opencodeModelKey(model) { + return model?.providerID && model?.modelID ? `${model.providerID}/${model.modelID}` : ''; +} + +function terminalSubmit(value) { + return `${value}\r`; +} + function normalizePrompt(value) { return String(value ?? '').replace(/\r\n/g, '\n').trim(); } diff --git a/evcod_warp/test/native-agent.test.js b/evcod_warp/test/native-agent.test.js index 8efd9e7..6817e90 100644 --- a/evcod_warp/test/native-agent.test.js +++ b/evcod_warp/test/native-agent.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, utimesSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { NativeAgentController, encodeProjectPath, getAgentConfig } from '../src/native-agent.js'; @@ -427,6 +427,35 @@ for (const agentType of ['claude', 'codex', 'qwen']) { }); } +test('submits multi-line web prompts as a single bracketed paste, not line-by-line', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-multiline-')); + const calls = []; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'claude', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.inputReadyAt = 0; + + const single = await controller.promptFromChat('just one line', 'turn-1', 'web'); + assert.equal(single, true); + assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', 'just one line\r']); + + await controller.promptFromChat('line one\nline two\nline three', 'turn-2', 'web'); + // Wrapped in bracketed paste so the TUI keeps the newlines literal, then one CR. + assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', '\x1b[200~line one\nline two\nline three\x1b[201~\r']); +}); + test('mirrors Codex response_item user and event_msg assistant records into CORE', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); @@ -477,7 +506,7 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE JSON.stringify({ type: 'response_item', timestamp: 'u1', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'from response item' }] } }), JSON.stringify({ type: 'event_msg', timestamp: 'r1', payload: { type: 'agent_reasoning_delta', delta: 'inspect ' } }), JSON.stringify({ type: 'event_msg', timestamp: 'r2', payload: { type: 'agent_reasoning_delta', delta: 'state' } }), - JSON.stringify({ type: 'event_msg', timestamp: 'a1', payload: { type: 'agent_message', message: 'answer from event' } }), + JSON.stringify({ type: 'response_item', timestamp: 'a1', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer from event' }] } }), JSON.stringify({ type: 'event_msg', timestamp: 'done1', payload: { type: 'task_complete', turn_id: 'codex-turn' } }), '', ].join('\n'), @@ -500,6 +529,72 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE await controller.stop(); }); +test('mirrors a CLI-side codex model switch back to the core as a terminal config change', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const transcriptPath = join(projectPath, 'codex-model.jsonl'); + const calls = []; + + const core = { + async updateSession(id, patch) { + calls.push(['updateSession', id, patch]); + return { id, ...patch }; + }, + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `message-${calls.length}`, ...message }; + }, + async sendUserMessage(conversationId, content, source) { + return { messages: [{ id: 'u', role: 'user', turnId: 't', content, source }] }; + }, + async acquireLock(id, owner, turnId) { + return { id, lockOwner: owner, lockTurnId: turnId }; + }, + async releaseLock(id, owner, status) { + return { id, status }; + }, + async completeTurn() {}, + }; + + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'codex', + projectPath, + homeDir, + model: 'gpt-5', + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = transcriptPath; + controller.session = { id: 'session-1' }; + + await writeFile( + transcriptPath, + [ + // Same model as launch -> no redundant push. + JSON.stringify({ type: 'turn_context', timestamp: 'tc1', payload: { turn_id: 't1', cwd: projectPath, model: 'gpt-5' } }), + // User switched the model in the TUI -> push back to core (source terminal). + JSON.stringify({ type: 'turn_context', timestamp: 'tc2', payload: { turn_id: 't2', cwd: projectPath, model: 'gpt-5.5' } }), + '', + ].join('\n'), + 'utf8', + ); + await controller.syncOnce(); + + const updates = calls.filter((call) => call[0] === 'updateSession'); + assert.equal(updates.length, 1); + assert.deepEqual(updates[0], ['updateSession', 'session-1', { model: 'gpt-5.5', configSource: 'terminal' }]); + // session_config signals must not leak into the chat as messages. + assert.equal(calls.filter((call) => call[0] === 'createAgentMessage').length, 0); + + await controller.stop(); +}); + test('native agent cancel sends Ctrl-C to the native terminal and releases the turn', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); @@ -554,6 +649,117 @@ test('native agent cancel sends Ctrl-C to the native terminal and releases the t assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']); }); +test('web model changes are injected into the native TUI, terminal-origin ones are not', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-model-')); + const calls = []; + let relayHandler; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'codex', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.session = { id: 'session-1' }; + controller.subscribe(); + + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' }, + }); + // Core also emits the broader config event for the same model change; the + // native TUI should receive one slash command, not two. + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' }, + }); + // A change the CLI already applied (launch/terminal) must not be re-typed. + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5', source: 'launch' }, + }); + // A change for a different conversation must be ignored. + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'other', model: 'gpt-4', source: 'web' }, + }); + await new Promise((resolveWait) => setTimeout(resolveWait, 0)); + + assert.deepEqual(calls, [['inputPane', 'pane-1', '/model gpt-5-codex\r']]); +}); + +test('native agent consumes full session config changes without echo loops', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-config-')); + const calls = []; + let relayHandler; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'claude', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.session = { id: 'session-1' }; + controller.subscribe(); + + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { + conversationId: 'conversation-1', + model: 'claude-sonnet-4-6', + permissionMode: 'ask-first', + effort: 'high', + fastMode: true, + planMode: true, + source: 'web', + }, + }); + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { conversationId: 'conversation-1', model: 'claude-opus-4-8', source: 'terminal' }, + }); + await new Promise((resolveWait) => setTimeout(resolveWait, 0)); + + assert.equal(controller.model, 'claude-opus-4-8'); + assert.equal(controller.permissionMode, 'ask-first'); + assert.equal(controller.effort, 'high'); + assert.equal(controller.fastMode, true); + assert.equal(controller.planMode, true); + assert.deepEqual(calls, [['inputPane', 'pane-1', '/model claude-sonnet-4-6\r']]); +}); + test('native permission responses are mapped to provider TUI input', async () => { const cases = [ { agentType: 'claude', allow: '1\r', deny: '2\r' }, @@ -968,6 +1174,117 @@ test('starts a new terminal-origin turn without inheriting the previous active t await controller.stop(); }); +test('follows a transcript rotation within a scoped project dir without losing messages', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const transcriptDir = join(homeDir, '.claude', 'projects', encodeProjectPath(projectPath)); + await mkdir(transcriptDir, { recursive: true }); + const fileA = join(transcriptDir, 'a.jsonl'); + const fileB = join(transcriptDir, 'b.jsonl'); + const calls = []; + const core = { + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `m${calls.length}`, ...message }; + }, + async completeTurn() {}, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'c1', + paneId: 'p1', + agentType: 'claude', + projectPath, + homeDir, + env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' }, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('claude', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = fileA; + + await writeFile(fileA, JSON.stringify({ type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'answer A' }] } }) + '\n', 'utf8'); + utimesSync(fileA, new Date(Date.now() - 10000), new Date(Date.now() - 10000)); + await controller.syncOnce(); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer A')); + + // A fresh session file appears (rotation) while file A goes idle. + await writeFile(fileB, JSON.stringify({ type: 'assistant', uuid: 'b1', message: { content: [{ type: 'text', text: 'answer B' }] } }) + '\n', 'utf8'); + utimesSync(fileB, new Date(Date.now() + 10000), new Date(Date.now() + 10000)); + await controller.syncOnce(); + + assert.equal(controller.transcriptPath, fileB); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer B')); + + await controller.stop(); +}); + +test('does not follow a newer codex rollout belonging to a different project', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const sessionsDir = join(homeDir, '.codex', 'sessions'); + await mkdir(sessionsDir, { recursive: true }); + const ours = join(sessionsDir, 'ours.jsonl'); + const foreign = join(sessionsDir, 'foreign.jsonl'); + const calls = []; + const core = { + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `m${calls.length}`, ...message }; + }, + async completeTurn() {}, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'c1', + paneId: 'p1', + agentType: 'codex', + projectPath, + homeDir, + env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' }, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = ours; + + await writeFile( + ours, + [ + JSON.stringify({ type: 'session_meta', payload: { cwd: projectPath } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer ours' }] } }), + '', + ].join('\n'), + 'utf8', + ); + utimesSync(ours, new Date(Date.now() - 10000), new Date(Date.now() - 10000)); + await controller.syncOnce(); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer ours')); + + await writeFile( + foreign, + [ + JSON.stringify({ type: 'session_meta', payload: { cwd: '/some/other/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer foreign' }] } }), + '', + ].join('\n'), + 'utf8', + ); + utimesSync(foreign, new Date(Date.now() + 10000), new Date(Date.now() + 10000)); + await controller.syncOnce(); + + assert.equal(controller.transcriptPath, ours); + assert(!calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer foreign')); + + await controller.stop(); +}); + function transcriptRecords(agentType, userContent, assistantContent, suffix) { if (agentType === 'claude') { return [ diff --git a/evcod_warp/test/normalizer.test.js b/evcod_warp/test/normalizer.test.js index b5f7485..16e06af 100644 --- a/evcod_warp/test/normalizer.test.js +++ b/evcod_warp/test/normalizer.test.js @@ -259,14 +259,14 @@ test('normalizes Codex TodoWrite function calls as todo lists', () => { }); test('normalizes Codex rollout transcript records', () => { + // Codex mirrors each user prompt as both an event_msg/user_message and a + // durable response_item; only the response_item is mirrored to avoid duplicates. const user = normalizeCodexTranscriptLine({ type: 'event_msg', timestamp: 'u1', payload: { type: 'user_message', message: 'typed in codex tui', images: [], local_images: [], text_elements: [] }, }); - assert.equal(user[0].role, 'user'); - assert.equal(user[0].kind, 'text'); - assert.equal(user[0].content, 'typed in codex tui'); + assert.deepEqual(user, []); const responseUser = normalizeCodexTranscriptLine({ type: 'response_item', @@ -306,15 +306,15 @@ test('normalizes Codex rollout transcript records', () => { assert.equal(complete[0].finalizesTurn, true); }); -test('normalizes Codex event_msg assistant records from transcripts', () => { +test('ignores duplicate Codex event_msg messages but keeps reasoning and errors', () => { + // agent_message duplicates the durable response_item assistant message, so it + // is ignored to avoid double-mirroring every reply into the web chat. const text = normalizeCodexTranscriptLine({ type: 'event_msg', timestamp: 'a1', payload: { type: 'agent_message', message: 'assistant event text' }, }); - assert.equal(text[0].role, 'assistant'); - assert.equal(text[0].kind, 'text'); - assert.equal(text[0].content, 'assistant event text'); + assert.deepEqual(text, []); const reasoning = normalizeCodexTranscriptLine({ type: 'event_msg', @@ -330,8 +330,7 @@ test('normalizes Codex event_msg assistant records from transcripts', () => { timestamp: 'a2', payload: { type: 'assistant_message', message: 'assistant alias text' }, }); - assert.equal(assistantAlias[0].kind, 'text'); - assert.equal(assistantAlias[0].content, 'assistant alias text'); + assert.deepEqual(assistantAlias, []); const failed = normalizeCodexTranscriptLine({ type: 'event_msg', @@ -343,6 +342,30 @@ test('normalizes Codex event_msg assistant records from transcripts', () => { assert.equal(failed[0].finalizesTurn, true); }); +test('surfaces the Codex active model from turn_context as a session_config signal', () => { + const cfg = normalizeCodexTranscriptLine({ + type: 'turn_context', + timestamp: 'tc1', + payload: { turn_id: 'turn-1', cwd: '/tmp/project', model: 'gpt-5.5', approval_policy: 'never' }, + }); + assert.equal(cfg.length, 1); + assert.equal(cfg[0].kind, 'session_config'); + assert.deepEqual(cfg[0].payload, { model: 'gpt-5.5', permissionMode: 'ask-first' }); + + const none = normalizeCodexTranscriptLine({ type: 'turn_context', payload: { turn_id: 'turn-2' } }); + assert.deepEqual(none, []); +}); + +test('surfaces Codex approval and effort from turn_context as session config', () => { + const cfg = normalizeCodexTranscriptLine({ + type: 'turn_context', + timestamp: 't1', + payload: { turn_id: 'turn-1', approval_policy: 'never', reasoning_effort: 'medium' }, + }); + assert.equal(cfg[0].kind, 'session_config'); + assert.deepEqual(cfg[0].payload, { permissionMode: 'ask-first', effort: 'medium' }); +}); + test('ignores synthetic Codex user context records', () => { const messages = normalizeCodexTranscriptLine({ type: 'response_item', diff --git a/evcod_warp/test/opencode-tui.test.js b/evcod_warp/test/opencode-tui.test.js index 21b87fd..f5dcba3 100644 --- a/evcod_warp/test/opencode-tui.test.js +++ b/evcod_warp/test/opencode-tui.test.js @@ -291,6 +291,88 @@ test('opencode cancel aborts the server and releases the active turn as canceled assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']); }); +test('opencode consumes session config changes and uses the new model', async () => { + const calls = []; + let relayHandler; + const core = { + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + async acquireLock(sessionId, owner, turnId, expectedVersion, providerSessionId) { + calls.push(['acquireLock', sessionId, owner, turnId, expectedVersion, providerSessionId]); + return { id: sessionId, lockOwner: owner, turnId }; + }, + async releaseLock(sessionId, owner, status) { + calls.push(['releaseLock', sessionId, owner, status]); + return { id: sessionId, status }; + }, + async completeTurn(conversationId, turnId, status) { + calls.push(['completeTurn', conversationId, turnId, status]); + }, + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `message-${calls.length}`, ...message }; + }, + close() {}, + }; + const controller = new OpencodeTuiController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + projectId: 'project-1', + cwd: process.cwd(), + model: 'openai/gpt-5.4', + }); + controller.session = { id: 'session-1' }; + controller.sessionId = 'opencode-session'; + controller.server = { + async sendMessage(sessionId, prompt, model) { + calls.push(['sendMessage', sessionId, prompt, model]); + }, + async listMessages() { + return [{ info: { id: 'assistant-1', role: 'assistant', time: { completed: 1 } }, parts: [{ id: 'text', type: 'text', text: 'ok' }] }]; + }, + }; + + controller.subscribe(); + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { + conversationId: 'conversation-1', + model: 'anthropic/claude-sonnet-4-6', + permissionMode: 'ask-first', + effort: 'high', + fastMode: true, + planMode: true, + source: 'web', + }, + }); + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'anthropic/claude-sonnet-4-6', source: 'web' }, + }); + await controller.promptFromChat('hello', 'turn-1', 'web'); + + assert.equal(controller.permissionMode, 'ask-first'); + assert.equal(controller.effort, 'high'); + assert.equal(controller.fastMode, true); + assert.equal(controller.planMode, true); + assert.deepEqual(calls.find((call) => call[0] === 'inputPane'), ['inputPane', 'pane-1', '/model anthropic/claude-sonnet-4-6\r']); + assert.equal(calls.filter((call) => call[0] === 'inputPane').length, 1); + assert.deepEqual(calls.find((call) => call[0] === 'sendMessage'), [ + 'sendMessage', + 'opencode-session', + 'hello', + { providerID: 'anthropic', modelID: 'claude-sonnet-4-6' }, + ]); +}); + test('opencode catchUpPending replays every pending web prompt after the last assistant message', async () => { const prompts = []; const core = {