From 3973e44ac2f543f719052b9d1f8a237c3a17755d Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:06:47 +0800 Subject: [PATCH 1/8] fix(windows): stabilize agent runtime and packaging --- .github/workflows/check-windows.yml | 121 +++++++ apps/server/package.json | 2 +- apps/server/src/domains/chat-service.ts | 9 +- apps/server/src/main.ts | 40 +-- .../src/runtimes/local-agent-command.ts | 29 +- .../src/runtimes/local-agent-provider.ts | 314 ++++++++---------- apps/server/src/runtimes/runtime-provider.ts | 8 +- pnpm-lock.yaml | 10 +- scripts/package-tutti-app.mjs | 26 +- tests/agent-id-runtime-selection.test.mjs | 26 +- tests/agent-invocation-lifecycle.test.mjs | 66 ++-- tests/fixtures/windows/tutti-fixture.cs | 35 ++ tests/local-agent-command.test.mjs | 38 ++- tests/local-agent-effective-cwd.test.mjs | 74 ++--- ...local-agent-workspace-app-routing.test.mjs | 30 +- tests/package-tutti-app.test.mjs | 1 + 16 files changed, 505 insertions(+), 324 deletions(-) create mode 100644 .github/workflows/check-windows.yml create mode 100644 tests/fixtures/windows/tutti-fixture.cs diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml new file mode 100644 index 0000000..1447a71 --- /dev/null +++ b/.github/workflows/check-windows.yml @@ -0,0 +1,121 @@ +name: Windows Runtime Check + +on: + pull_request: + paths: + - ".github/workflows/check-windows.yml" + - "apps/**" + - "packages/**" + - "scripts/**" + - "tests/**" + - "package.json" + - "pnpm-lock.yaml" + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - name: Check shell-free command parsing + run: node --test tests/local-agent-command.test.mjs + - run: pnpm package:tutti + - name: Smoke packaged server with native Tutti CLI + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $fixtureDir = Join-Path $env:RUNNER_TEMP 'Tutti CLI Fixture' + $fixturePath = Join-Path $fixtureDir 'tutti.exe' + $fixtureLog = Join-Path $env:RUNNER_TEMP 'tutti-fixture-argv.log' + New-Item -ItemType Directory -Force -Path $fixtureDir | Out-Null + Add-Type -Path 'tests/fixtures/windows/tutti-fixture.cs' -OutputAssembly $fixturePath -OutputType ConsoleApplication + + $env:TUTTI_CLI = (Resolve-Path $fixturePath).Path + $env:GROUP_CHAT_TUTTI_FIXTURE_LOG = $fixtureLog + $env:TUTTI_APP_DATA_DIR = Join-Path $env:RUNNER_TEMP 'group-chat-home' + $env:TUTTI_APP_HOST = '127.0.0.1' + $env:TUTTI_APP_PORT = '18788' + $env:TUTTI_APP_NODE = (Get-Command node).Source + $stdout = Join-Path $env:RUNNER_TEMP 'group-chat-server.stdout.log' + $stderr = Join-Path $env:RUNNER_TEMP 'group-chat-server.stderr.log' + $bootstrap = (Resolve-Path 'build/tutti-app/package/bootstrap.sh').Path + $gitBash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + if (-not (Test-Path $gitBash)) { throw "Git Bash not found at $gitBash" } + $server = Start-Process -FilePath $gitBash -ArgumentList $bootstrap -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr + try { + $healthy = $false + for ($attempt = 0; $attempt -lt 40; $attempt++) { + try { + $health = Invoke-RestMethod 'http://127.0.0.1:18788/api/health' + if ($health.ok) { $healthy = $true; break } + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $healthy) { throw "Packaged server did not become healthy.`n$(Get-Content $stderr -Raw)" } + + # Run one startup snapshot before creating work so recovery maintenance cannot classify the test run as stale. + Invoke-RestMethod 'http://127.0.0.1:18788/api/bootstrap' | Out-Null + $catalog = Invoke-RestMethod 'http://127.0.0.1:18788/api/local-agent/agents' + if ($catalog.defaultAgentTargetId -ne 'fixture:codex') { throw 'Exact fixture Agent Target was not selected' } + if ($catalog.agents[0].agentTargetId -ne 'fixture:codex') { throw 'Agent catalog lost exact target identity' } + + $jsonHeaders = @{ 'Content-Type' = 'application/json' } + $identity = Invoke-RestMethod -Method Post -Headers $jsonHeaders -Uri 'http://127.0.0.1:18788/api/identities' -Body '{"name":"Windows Agent"}' + $room = Invoke-RestMethod -Method Post -Headers $jsonHeaders -Uri 'http://127.0.0.1:18788/api/rooms' -Body '{"title":"Windows Runtime"}' + $participantBody = @{ identityId = $identity.identity.id; runtimeProfileId = $identity.identity.defaultRuntimeProfileId } | ConvertTo-Json -Compress + $participant = Invoke-RestMethod -Method Post -Headers $jsonHeaders -Uri "http://127.0.0.1:18788/api/conversations/$($room.conversation.id)/participants" -Body $participantBody + $messageBody = @{ + content = '@Windows Agent run the native fixture' + maxReplyRounds = 1 + mentions = @(@{ + mentionType = 'participant' + participantId = $participant.participant.id + displayNameSnapshot = $participant.participant.displayName + }) + } | ConvertTo-Json -Depth 4 -Compress + Invoke-RestMethod -Method Post -Headers $jsonHeaders -Uri "http://127.0.0.1:18788/api/conversations/$($room.conversation.id)/messages" -Body $messageBody | Out-Null + + $completedRun = $null + $assistant = $null + for ($attempt = 0; $attempt -lt 80; $attempt++) { + $snapshot = Invoke-RestMethod 'http://127.0.0.1:18788/api/bootstrap' + $completedRun = $snapshot.agentRuns | Where-Object { $_.participantId -eq $participant.participant.id -and $_.status -eq 'completed' } | Select-Object -First 1 + if ($completedRun) { + $assistant = $snapshot.messages | Where-Object { $_.id -eq $completedRun.assistantMessageId } | Select-Object -First 1 + if ($assistant.content -eq 'windows-agent-ok') { break } + } + Start-Sleep -Milliseconds 250 + } + if (-not $completedRun -or $assistant.content -ne 'windows-agent-ok') { + throw "Native Windows Agent run did not complete through Group Chat.`n$(Get-Content $stderr -Raw)" + } + + $calls = Get-Content $fixtureLog + if (-not ($calls | Where-Object { $_ -eq "--json$([char]0x1f)agent$([char]0x1f)list" })) { + throw "Native Tutti CLI did not receive the exact list argv: $($calls -join '; ')" + } + $expectedComposer = "--json$([char]0x1f)agent$([char]0x1f)composer-options$([char]0x1f)--agent-id$([char]0x1f)fixture:codex" + if (-not ($calls | Where-Object { $_ -eq $expectedComposer })) { + throw "Native Tutti CLI did not receive the exact composer argv: $($calls -join '; ')" + } + $runPrefix = "RUN$([char]0x1f)exec$([char]0x1f)--json$([char]0x1f)--skip-git-repo-check$([char]0x1f)" + $runCall = $calls | Where-Object { $_.StartsWith($runPrefix) } | Select-Object -First 1 + if (-not $runCall -or $runCall -notlike "*$([char]0x1f)-C$([char]0x1f)*") { + throw "Native Agent executable was not launched: $($calls -join '; ')" + } + } finally { + if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force } + } diff --git a/apps/server/package.json b/apps/server/package.json index 8b1f5c5..7e62210 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,7 +19,7 @@ "@fastify/static": "^8.3.0", "@fastify/websocket": "^11.2.0", "@group-chat/shared": "workspace:*", - "@tutti-os/agent-acp-kit": "0.5.0", + "@tutti-os/agent-acp-kit": "0.7.8", "fastify": "^5.8.5", "nanoid": "^5.1.15" }, diff --git a/apps/server/src/domains/chat-service.ts b/apps/server/src/domains/chat-service.ts index 9172ae6..6737684 100644 --- a/apps/server/src/domains/chat-service.ts +++ b/apps/server/src/domains/chat-service.ts @@ -52,7 +52,7 @@ import { parseLegacyTuttiAgentProviderParticipantId, parseTuttiAgentParticipantId, } from "./tutti-agent-participant.js"; -import type { DetectContext, ManagedAgentInvocationCredentialHeaders } from "@tutti-os/agent-acp-kit"; +import type { DetectContext } from "@tutti-os/agent-acp-kit"; import { extractLocalFilePathsFromContent, inferMimeTypeForPath, @@ -75,7 +75,6 @@ const AUTO_IMPORT_RUN_FILE_MAX_BYTES = 50 * 1024 * 1024; interface RuntimeInvocationContext { agentDetectContext?: DetectContext; defaultAgentTargetId?: string; - managedAgentHeaders?: ManagedAgentInvocationCredentialHeaders; } export class ChatService { @@ -118,7 +117,7 @@ export class ChatService { async listLocalAgentTargets(detectContext?: DetectContext) { const catalog = await this.runtimes.listLocalAgentTargets(detectContext); this.repo.syncLocalAgentCatalog({ - authoritative: !detectContext?.managedAgentInvocation, + authoritative: !detectContext, agents: catalog.agents.map((agent) => ({ agentTargetId: agent.agentTargetId, providerId: agent.providerId, @@ -353,7 +352,6 @@ export class ChatService { recentMessages: [], attachments: [], agentDetectContext: invocation.agentDetectContext, - managedAgentHeaders: invocation.managedAgentHeaders, }); const localCompaction = this.workspaces.compactConversationContext({ conversation, participant }); return { @@ -1543,7 +1541,6 @@ export class ChatService { recentMessages, attachments, agentDetectContext: invocation?.agentDetectContext, - managedAgentHeaders: invocation?.managedAgentHeaders, }; const runDescriptor = provider.describeRun(runtimeContext); const run = this.repo.createAgentRun({ @@ -1611,7 +1608,6 @@ export class ChatService { recentMessages, attachments, agentDetectContext: invocation?.agentDetectContext, - managedAgentHeaders: invocation?.managedAgentHeaders, }; const runDescriptor = provider.describeRun(runtimeContext); if (preacceptedRun && !agentRunMatchesRuntimeDescriptor(preacceptedRun, runDescriptor)) { @@ -2125,7 +2121,6 @@ export class ChatService { attachments, runId: runtimeRunId, agentDetectContext: input.invocation.agentDetectContext, - managedAgentHeaders: input.invocation.managedAgentHeaders, }; const now = new Date().toISOString(); const buildTask = (partial: Pick): PrivateTaskSnapshot => ({ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 15dd6bb..73258e7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -7,10 +7,6 @@ import multipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; import fastifyWebsocket from "@fastify/websocket"; import Fastify from "fastify"; -import { - createManagedAgentDetectContextFromHeaders, - type ManagedAgentInvocationCredentialHeaders, -} from "@tutti-os/agent-acp-kit"; import { parseLegacyTuttiAgentProviderParticipantId, parseTuttiAgentParticipantId, @@ -139,9 +135,7 @@ server.put<{ Body: Partial }>("/api/user-profile", async (req return { profile }; }); -server.get("/api/local-agent/agents", async (request) => - chat.listLocalAgentTargets(createManagedAgentDetectContextFromHeaders(request.headers)) -); +server.get("/api/local-agent/agents", async () => chat.listLocalAgentTargets()); server.post<{ Body: unknown }>("/tutti/cli/conversations/list", async (request, reply) => sendCliOutput(reply, listConversationsCliOutput(chat.bootstrap(), normalizeCliEnvelope(request.body))), @@ -177,7 +171,7 @@ server.post<{ Body: CreateRoomRequest }>("/api/rooms", async (request) => { (participant) => participant.runtimeProfileId === undefined, ) ?? false; const catalog = needsDefaultAgent - ? await chat.listLocalAgentTargets(createManagedAgentDetectContextFromHeaders(request.headers)) + ? await chat.listLocalAgentTargets() : null; return chat.createRoom(input, catalog?.defaultAgentTargetId); }); @@ -196,7 +190,7 @@ server.delete<{ Params: { roomId: string } }>("/api/rooms/:roomId", async (reque server.post<{ Body: CreateIdentityRequest }>("/api/identities", async (request) => { const catalog = request.body.defaultRuntimeProfileId === undefined - ? await chat.listLocalAgentTargets(createManagedAgentDetectContextFromHeaders(request.headers)) + ? await chat.listLocalAgentTargets() : null; const identity = chat.createIdentity(request.body, catalog?.defaultAgentTargetId); return { identity, runtimeProfile: chat.getRuntimeProfile(identity.defaultRuntimeProfileId) }; @@ -270,12 +264,7 @@ server.post<{ Params: { conversationId: string }; Body: PrivateTaskRequest }>( "/api/conversations/:conversationId/private-tasks", async (request, reply) => { try { - const managedAgentHeaders = request.headers as ManagedAgentInvocationCredentialHeaders; - const agentDetectContext = createManagedAgentDetectContextFromHeaders(managedAgentHeaders); - return chat.runPrivateTask(request.params.conversationId, request.body ?? {}, { - agentDetectContext, - managedAgentHeaders, - }); + return chat.runPrivateTask(request.params.conversationId, request.body ?? {}); } catch (error) { const message = error instanceof Error ? error.message : "Unable to start private task"; return reply.code(400).send({ error: message }); @@ -303,15 +292,11 @@ server.get<{ Params: { conversationId: string } }>( server.post<{ Params: { conversationId: string }; Body: SendMessageRequest }>( "/api/conversations/:conversationId/messages", async (request) => { - const managedAgentHeaders = request.headers as ManagedAgentInvocationCredentialHeaders; - const agentDetectContext = createManagedAgentDetectContextFromHeaders(managedAgentHeaders); const catalog = messageRequiresAgentCatalog(request.body) - ? await chat.listLocalAgentTargets(agentDetectContext) + ? await chat.listLocalAgentTargets() : null; return chat.sendMessage(request.params.conversationId, request.body, { - agentDetectContext, ...(catalog ? { defaultAgentTargetId: catalog.defaultAgentTargetId } : {}), - managedAgentHeaders, }); }, ); @@ -332,8 +317,6 @@ server.patch<{ Params: { messageId: string }; Body: UpdateMessageRequest }>( "/api/messages/:messageId", async (request, reply) => { try { - const managedAgentHeaders = request.headers as ManagedAgentInvocationCredentialHeaders; - const agentDetectContext = createManagedAgentDetectContextFromHeaders(managedAgentHeaders); const currentMessage = request.body.status === "recalled" ? null : chat.getMessage(request.params.messageId); @@ -341,12 +324,10 @@ server.patch<{ Params: { messageId: string }; Body: UpdateMessageRequest }>( content: request.body.content ?? currentMessage.content, mentions: request.body.mentions ?? currentMessage.mentions, }) - ? await chat.listLocalAgentTargets(agentDetectContext) + ? await chat.listLocalAgentTargets() : null; const result = await chat.updateMessage(request.params.messageId, request.body, { - agentDetectContext, ...(catalog ? { defaultAgentTargetId: catalog.defaultAgentTargetId } : {}), - managedAgentHeaders, }); if (!result) return reply.code(404).send({ error: "Message not found" }); return result; @@ -484,13 +465,8 @@ server.post<{ Params: { conversationId: string; participantId: string } }>( "/api/conversations/:conversationId/participants/:participantId/context-compact", async (request, reply) => { try { - const managedAgentHeaders = request.headers as ManagedAgentInvocationCredentialHeaders; - const agentDetectContext = createManagedAgentDetectContextFromHeaders(managedAgentHeaders); - await chat.listLocalAgentTargets(agentDetectContext); - return await chat.compactParticipantContext(request.params.conversationId, request.params.participantId, { - agentDetectContext, - managedAgentHeaders, - }); + await chat.listLocalAgentTargets(); + return await chat.compactParticipantContext(request.params.conversationId, request.params.participantId); } catch (error) { const message = error instanceof Error ? error.message : "Unable to compact context"; return reply.code(message.includes("not found") ? 404 : 400).send({ error: message }); diff --git a/apps/server/src/runtimes/local-agent-command.ts b/apps/server/src/runtimes/local-agent-command.ts index ac6b6ee..0497fef 100644 --- a/apps/server/src/runtimes/local-agent-command.ts +++ b/apps/server/src/runtimes/local-agent-command.ts @@ -1,11 +1,36 @@ +export interface LocalAgentCommand { + command: string; + args: string[]; +} + export function resolveLocalAgentCommand( providerId: string, env: Readonly> = process.env, -) { +): LocalAgentCommand | null { const provider = providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_"); const providerSpecific = provider ? env[`GROUP_CHAT_LOCAL_AGENT_${provider}_COMMAND`] : undefined; const legacyAlias = provider === "CLAUDE_CODE" ? env.GROUP_CHAT_LOCAL_AGENT_CLAUDE_COMMAND : undefined; - return providerSpecific || legacyAlias || env.GROUP_CHAT_LOCAL_AGENT_COMMAND || ""; + const configured = (providerSpecific || legacyAlias || env.GROUP_CHAT_LOCAL_AGENT_COMMAND || "").trim(); + if (!configured) return null; + + if (!configured.startsWith("[")) { + return { command: configured, args: [] }; + } + + let argv: unknown; + try { + argv = JSON.parse(configured); + } catch { + throw new Error("Configured local Agent command must be an executable path or a JSON argv array."); + } + if ( + !Array.isArray(argv) + || argv.length === 0 + || argv.some((item) => typeof item !== "string" || !item.trim()) + ) { + throw new Error("Configured local Agent command JSON must contain non-empty string arguments."); + } + return { command: argv[0]!, args: argv.slice(1) }; } diff --git a/apps/server/src/runtimes/local-agent-provider.ts b/apps/server/src/runtimes/local-agent-provider.ts index f79a6d5..4b9c7e1 100644 --- a/apps/server/src/runtimes/local-agent-provider.ts +++ b/apps/server/src/runtimes/local-agent-provider.ts @@ -6,9 +6,6 @@ import { fileURLToPath } from "node:url"; import { createDefaultLocalAgentProviderPlugins, createDefaultLocalAgentRuntime, - createManagedAgentRunContextFromHeaders, - getManagedAgentInvocationCredentialFromHeaders, - isManagedAgentInvocationProviderId, type AgentEvent, type AgentRunMessage, type LocalAgentMcpServerConfig, @@ -17,12 +14,11 @@ import { type RawAgentStream, type DetectContext, } from "@tutti-os/agent-acp-kit"; +import { resolveProcessInvocation } from "@tutti-os/agent-acp-kit/process-adapter"; import { - loadTuttiAgentCatalog, loadTuttiAgentComposerOptions, loadTuttiAgentSkillContext, resolveTuttiCliCommand, - type TuttiAgentCatalogEntry, type TuttiAgentSkillContext, } from "@tutti-os/agent-acp-kit/tutti"; import { @@ -47,7 +43,7 @@ import { import type { RuntimeProvider, RuntimeReplyContext, RuntimeStreamEvent } from "./runtime-provider.js"; import { RuntimeProviderUnsupportedError } from "./runtime-provider.js"; import { buildLocalAgentProcessEnv } from "./local-agent-env.js"; -import { resolveLocalAgentCommand } from "./local-agent-command.js"; +import { resolveLocalAgentCommand, type LocalAgentCommand } from "./local-agent-command.js"; import { LocalAgentSessionStore } from "./local-agent-session-store.js"; type GroupChatLocalAgentProviderPlugin = LocalAgentProviderPlugin<"local-agent", string>; @@ -96,66 +92,61 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { } async listLocalAgentTargets(detectContext?: DetectContext): Promise { - const env = buildLocalAgentProcessEnv(process.env, tuttiCliEnv()); + const detections = await this.detectLocalAgentTargets(detectContext); const cwd = detectContext?.cwd?.trim() || process.cwd(); - const catalog = await loadTuttiAgentCatalog({ - runtime: this.localAgentRuntime, - cwd, - env, - detectContext, - commandEnvNames: ["GROUP_CHAT_TUTTI_CLI"], - }); - const detections = catalog.source === "standalone" - ? await this.localAgentRuntime.detect(detectContext) - : []; - const detectedByProvider = new Map(detections.map((item) => [item.provider, item])); - const agents = await Promise.all(catalog.agents.map(async (agent): Promise => { - const detected = detectedByProvider.get(agent.providerId); + const env = buildLocalAgentProcessEnv(process.env, tuttiCliEnv()); + const agents = (await Promise.all(detections.map(async (agent): Promise => { + if (!agent.agentTargetId) return null; let composer: Awaited> | null = null; try { composer = await loadTuttiAgentComposerOptions({ runtime: this.localAgentRuntime, agentTargetId: agent.agentTargetId, cwd, - env, + env: { ...env, ...detectContext?.env }, detectContext, commandEnvNames: ["GROUP_CHAT_TUTTI_CLI"], }); } catch { - // Catalog visibility is authoritative; composer metadata is optional UI enrichment. + // Exact target discovery remains authoritative; composer is UI-only enrichment. } const reasoningEfforts = composer?.reasoningConfig.options .map((option) => parseReasoningEffort(option.value)) - .filter((effort): effort is ReasoningEffort => effort !== null) - ?? undefined; - const configDir = readString(toRecord(detected), "configDir"); + .filter((effort): effort is ReasoningEffort => effort !== null); const status: LocalAgentTargetStatus = { agentTargetId: agent.agentTargetId, - providerId: agent.providerId, - provider: agent.providerId, + providerId: agent.provider, + provider: agent.provider, displayName: agent.displayName, - runtimeSupported: agent.runtimeSupported, - availabilityStatus: agent.availability.status, - available: agent.runtimeSupported && agent.availability.status === "available", - authState: authStateFromCatalog(agent, detected?.authState), - executablePath: "", - version: detected?.supported ? "detected" : "not-installed", - ...(configDir ? { configDir } : {}), - models: composer?.modelConfig.options.map((option) => ({ - id: option.value, - label: option.label, - ...(option.description ? { description: option.description } : {}), - })) ?? (detected?.models ?? []).map((model) => ({ id: model.id, label: model.label })), - defaultModelId: composer?.modelConfig.currentValue || composer?.modelConfig.defaultValue || undefined, + runtimeSupported: true, + availabilityStatus: agent.supported ? "available" : "unavailable", + available: agent.supported, + authState: agent.authState, + executablePath: agent.executablePath ?? "", + version: agent.supported ? "detected" : "not-installed", + models: agent.models.map((model) => ({ + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + })), + ...(agent.defaultModelId ? { defaultModelId: agent.defaultModelId } : {}), ...(reasoningEfforts?.length ? { reasoningEfforts } : {}), - defaultReasoningEffort: parseReasoningEffort(composer?.reasoningConfig.currentValue) ?? undefined, - speedModes: composer?.speedConfig.options.map((option) => ({ id: option.value, label: option.label })), - defaultSpeedMode: composer?.speedConfig.currentValue || composer?.speedConfig.defaultValue || undefined, - reason: agent.availability.status === "available" && agent.runtimeSupported ? undefined : agent.availability.detail, + ...(parseReasoningEffort(composer?.reasoningConfig.currentValue) + ? { defaultReasoningEffort: parseReasoningEffort(composer?.reasoningConfig.currentValue)! } + : {}), + ...(composer ? { + speedModes: composer.speedConfig.options.map((option) => ({ id: option.value, label: option.label })), + defaultSpeedMode: composer.speedConfig.currentValue || composer.speedConfig.defaultValue || undefined, + } : {}), + ...(agent.reason ? { reason: agent.reason } : {}), }; return enrichLocalAgentTargetStatus(status); - })); - return { defaultAgentTargetId: catalog.defaultAgentTargetId, agents }; + }))).filter((agent): agent is LocalAgentTargetStatus => agent !== null); + const defaultAgentTargetId = detections.find((agent) => agent.isDefault)?.agentTargetId + ?? detections.find((agent) => agent.supported)?.agentTargetId + ?? detections[0]?.agentTargetId + ?? ""; + return { defaultAgentTargetId, agents }; } async *streamReply(context: RuntimeReplyContext) { @@ -169,25 +160,51 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { return; } - yield* this.streamKitBridge(context, target.providerId, target.agentTargetId, target.legacyProviderUnique); + yield* this.streamKitBridge( + context, + target.providerId, + target.agentTargetId, + target.executablePath, + target.legacyProviderUnique, + ); } private async resolveExactTarget(agentTargetId: string, detectContext?: DetectContext) { - const catalog = await loadTuttiAgentCatalog({ - runtime: this.localAgentRuntime, - ...(detectContext?.cwd ? { cwd: detectContext.cwd } : {}), - env: buildLocalAgentProcessEnv(process.env, tuttiCliEnv()), - detectContext, - commandEnvNames: ["GROUP_CHAT_TUTTI_CLI"], - }); - const target = catalog.agents.find((agent) => agent.agentTargetId === agentTargetId); - if (!target) throw new RuntimeProviderUnsupportedError(`Unknown Agent target: ${agentTargetId}`); - const legacyProviderUnique = catalog.agents.filter((agent) => - agent.providerId === target.providerId - && agent.runtimeSupported - && agent.availability.status === "available" + if (!tuttiCliEnv().TUTTI_CLI) { + const customCommandTarget = customCommandTargetForAgentTargetId(agentTargetId); + if (customCommandTarget) return customCommandTarget; + } + const detections = await this.detectLocalAgentTargets(detectContext); + const detection = detections.find((agent) => agent.agentTargetId === agentTargetId); + if (!detection) { + const customCommandTarget = customCommandTargetForAgentTargetId(agentTargetId); + if (customCommandTarget) return customCommandTarget; + throw new RuntimeProviderUnsupportedError(`Unknown Agent target: ${agentTargetId}`); + } + const legacyProviderUnique = detections.filter((agent) => + agent.provider === detection.provider && agent.supported ).length === 1; - return { ...target, legacyProviderUnique }; + return { + agentTargetId, + providerId: detection.provider, + runtimeSupported: true, + availability: { + status: detection.supported ? "available" as const : "unavailable" as const, + detail: detection.reason ?? "Agent target is unavailable.", + }, + executablePath: detection.executablePath, + legacyProviderUnique, + }; + } + + private detectLocalAgentTargets(detectContext?: DetectContext) { + const cwd = detectContext?.cwd?.trim() || process.cwd(); + const env = buildLocalAgentProcessEnv(process.env, tuttiCliEnv()); + return this.localAgentRuntime.detect({ + ...detectContext, + cwd, + env: { ...env, ...detectContext?.env }, + }); } private async resolveExactTargetForRun(context: RuntimeReplyContext) { @@ -204,21 +221,9 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { private async prepareEffectiveRunContext(context: RuntimeReplyContext, runId: string) { const workspaceRoot = participantWorkspaceRoot(context.conversation.roomId, context.participant.id); - const provisionalProvider = canonicalProviderId(context.runtimeProfile?.provider); - if (provisionalProvider && isManagedAgentInvocationProviderId(provisionalProvider)) { - await ensureManagedAgentRunContext(context, provisionalProvider, runId); - } - - let runCwd = effectiveLocalAgentRunCwd(context, workspaceRoot); + const runCwd = effectiveLocalAgentRunCwd(workspaceRoot); context.agentDetectContext = localAgentRunDetectContext(context, runCwd); - let target = await this.resolveExactTargetForRun(context); - - if (!context.managedAgentRunContext && hasManagedAgentCredential(context)) { - await ensureManagedAgentRunContext(context, target.providerId, runId); - runCwd = effectiveLocalAgentRunCwd(context, workspaceRoot); - context.agentDetectContext = localAgentRunDetectContext(context, runCwd); - target = await this.resolveExactTargetForRun(context); - } + const target = await this.resolveExactTargetForRun(context); return { target, @@ -231,24 +236,27 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { private async resolveUniqueLegacyProviderTarget(providerId: string | null | undefined, detectContext?: DetectContext) { const normalizedProviderId = canonicalProviderId(providerId); if (!normalizedProviderId) throw new RuntimeProviderUnsupportedError("Exact Agent target is required."); - const catalog = await loadTuttiAgentCatalog({ - runtime: this.localAgentRuntime, - ...(detectContext?.cwd ? { cwd: detectContext.cwd } : {}), - env: buildLocalAgentProcessEnv(process.env, tuttiCliEnv()), - detectContext, - commandEnvNames: ["GROUP_CHAT_TUTTI_CLI"], - }); - const matches = catalog.agents.filter((agent) => - agent.providerId === normalizedProviderId - && agent.runtimeSupported - && agent.availability.status === "available" + if (!tuttiCliEnv().TUTTI_CLI && resolveLocalAgentCommand(normalizedProviderId)) { + return customCommandTarget(normalizedProviderId); + } + const detections = await this.detectLocalAgentTargets(detectContext); + const matches = detections.filter((agent) => + agent.provider === normalizedProviderId && agent.supported && agent.agentTargetId ); if (matches.length !== 1) { throw new RuntimeProviderUnsupportedError( `Legacy provider cannot be migrated to one available Agent target: ${normalizedProviderId}`, ); } - return { ...matches[0]!, legacyProviderUnique: true }; + const match = matches[0]!; + return { + agentTargetId: match.agentTargetId!, + providerId: match.provider, + runtimeSupported: true, + availability: { status: "available" as const, detail: "" }, + executablePath: match.executablePath, + legacyProviderUnique: true, + }; } async cancel(runId: string) { @@ -300,7 +308,8 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { model: localAgentModelIdForAcp(context.runtimeProfile?.model ?? previousSession.model ?? "default", provider), reasoning: context.participant.reasoningEffort ?? undefined, env: buildLocalAgentRunEnv({ ...context, runId }), - managedAgentInvocation: context.managedAgentRunContext?.managedAgentInvocation, + agentTargetId, + executablePath: target.executablePath, metadata: context.participant.speedMode ? { speedMode: context.participant.speedMode } : undefined, extraAllowedDirs, resume, @@ -339,15 +348,7 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { }); } - private async *streamCommandBridge(context: RuntimeReplyContext, command: string) { - if (!command) { - const provider = context.runtimeProfile?.provider ?? "local-agent"; - const model = context.runtimeProfile?.model ?? context.participant.runtimeProfileId ?? "unknown"; - throw new RuntimeProviderUnsupportedError( - `${provider} runtime (${model}) is registered but no local command is configured.`, - ); - } - + private async *streamCommandBridge(context: RuntimeReplyContext, command: LocalAgentCommand) { let retryWithEnv: Record | undefined; let canRetryWithoutUserSkills = context.runtimeProfile?.provider === "codex"; while (true) { @@ -373,15 +374,21 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { private async *streamCommandBridgeAttempt( context: RuntimeReplyContext, - command: string, + command: LocalAgentCommand, envOverrides?: Record, ) { const workspaceRoot = participantWorkspaceRoot(context.conversation.roomId, context.participant.id); - const runCwd = effectiveLocalAgentRunCwd(context, workspaceRoot); - const child = spawn(command, { + const runCwd = effectiveLocalAgentRunCwd(workspaceRoot); + const env = buildLocalAgentRunEnv(context, envOverrides); + const invocation = resolveProcessInvocation({ + command: command.command, + args: command.args, + env, + }); + const child = spawn(invocation.command, invocation.args, { cwd: runCwd, - env: buildLocalAgentRunEnv(context, envOverrides), - shell: true, + env: invocation.env, + shell: false, stdio: "pipe", }); if (context.runId) { @@ -433,10 +440,11 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { context: RuntimeReplyContext, provider: string, agentTargetId: string, + executablePath: string | undefined, legacyProviderUnique: boolean, ) { const workspaceRoot = participantWorkspaceRoot(context.conversation.roomId, context.participant.id); - const runCwd = effectiveLocalAgentRunCwd(context, workspaceRoot); + const runCwd = effectiveLocalAgentRunCwd(workspaceRoot); const extraAllowedDirs = localAgentAllowedDirs(runCwd, workspaceRoot); const controller = new AbortController(); if (context.runId) { @@ -516,6 +524,8 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { conversationId: context.conversation.id, sessionId: context.conversation.id, provider, + agentTargetId, + ...(executablePath ? { executablePath } : {}), runtimeKind: "local-agent", runtimeProvider: provider, cwd: runCwd, @@ -527,7 +537,6 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { ...(mcpServers ? { mcpServers } : {}), ...(skillManifest ? { skillManifest } : {}), env: buildLocalAgentRunEnv(context, skillFallbackEnv), - managedAgentInvocation: context.managedAgentRunContext?.managedAgentInvocation, metadata: context.participant.speedMode ? { speedMode: context.participant.speedMode } : undefined, ...(timeoutMs ? { timeoutMs } : {}), extraAllowedDirs, @@ -612,6 +621,32 @@ export class LocalAgentRuntimeProvider implements RuntimeProvider { } } +function customCommandTargetForAgentTargetId(agentTargetId: string) { + if (!agentTargetId.startsWith("local:")) return null; + const providerId = canonicalProviderId(agentTargetId.slice("local:".length)); + if (!providerId || !resolveLocalAgentCommand(providerId)) return null; + return customCommandTarget(providerId); +} + +function customCommandTarget(providerId: string) { + return { + agentTargetId: `local:${providerId}`, + providerId, + runtimeSupported: true, + availability: { status: "available" as const, detail: "" }, + executablePath: undefined, + legacyProviderUnique: true, + }; +} + +const REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); + +function parseReasoningEffort(value: unknown): ReasoningEffort | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return REASONING_EFFORTS.has(normalized as ReasoningEffort) ? normalized as ReasoningEffort : null; +} + const SKILL_LOAD_FALLBACK_NOTICE = "检测到用户级 skill 元数据损坏,已临时隔离用户级 skills 并自动重试。"; const SKILL_BUNDLE_UNAVAILABLE_NOTICE = "Tutti skill bundle 暂时不可用,已跳过 skill bundle 注入并继续执行。"; const CONTEXT_WINDOW_FRESH_RETRY_NOTICE = "检测到 Codex 上下文窗口已满,已自动开启新线程并减少历史上下文重试。"; @@ -628,47 +663,17 @@ function localAgentRuntimeRunId(context: RuntimeReplyContext) { return context.runId ?? `${context.conversation.id}:${context.participant.id}`; } -async function ensureManagedAgentRunContext( - context: RuntimeReplyContext, - providerId: string, - runId: string, -) { - if (context.managedAgentRunContext || !hasManagedAgentCredential(context)) return; - context.managedAgentRunContext = await createManagedAgentRunContextFromHeaders( - context.managedAgentHeaders, - { providerId, runId }, - ); -} - -function hasManagedAgentCredential(context: RuntimeReplyContext) { - return Boolean(getManagedAgentInvocationCredentialFromHeaders(context.managedAgentHeaders)); -} - -function effectiveLocalAgentRunCwd( - context: RuntimeReplyContext, - participantWorkspaceRoot: string, -) { - return context.managedAgentRunContext?.cwd ?? participantWorkspaceRoot; +function effectiveLocalAgentRunCwd(participantWorkspaceRoot: string) { + return participantWorkspaceRoot; } function localAgentRunDetectContext( context: RuntimeReplyContext, runCwd: string, ): DetectContext { - const managedAgentInvocation = context.managedAgentRunContext?.managedAgentInvocation; - const credential = managedAgentInvocation?.credential; return { ...(context.agentDetectContext ?? {}), cwd: runCwd, - ...(managedAgentInvocation ? { managedAgentInvocation } : {}), - ...(credential - ? { - redactionSecrets: Array.from(new Set([ - ...(context.agentDetectContext?.redactionSecrets ?? []), - credential, - ])), - } - : {}), }; } @@ -778,43 +783,6 @@ function localAgentUnavailableReason( return `${displayName} is not available.`; } -function authStateFromCatalog( - provider: TuttiAgentCatalogEntry, - detected: LocalAgentTargetStatus["authState"] | undefined, -): LocalAgentTargetStatus["authState"] { - if (provider.availability.reasonCode === "auth_required") return "missing"; - if (provider.availability.reasonCode === "auth_expired") return "expired"; - return detected ?? "unknown"; -} - -const REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); - -function readString(record: Record | undefined, ...keys: string[]) { - if (!record) return undefined; - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - } - return undefined; -} - -function parseReasoningEffort(value: unknown): ReasoningEffort | null { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - return REASONING_EFFORTS.has(normalized as ReasoningEffort) ? (normalized as ReasoningEffort) : null; -} - -function parseReasoningEfforts(value: unknown): ReasoningEffort[] | undefined { - if (!Array.isArray(value)) return undefined; - const efforts = value - .map((item) => { - const itemRecord = toRecord(item); - return parseReasoningEffort(readString(itemRecord, "effort", "id", "value") ?? item); - }) - .filter((effort): effort is ReasoningEffort => effort !== null); - return efforts.length ? [...new Set(efforts)] : undefined; -} - function buildGroupChatMcpServers(context: RuntimeReplyContext): LocalAgentMcpServerConfig[] { if (!context.toolAccess?.token) return []; return [ diff --git a/apps/server/src/runtimes/runtime-provider.ts b/apps/server/src/runtimes/runtime-provider.ts index 2ac44a8..e766a34 100644 --- a/apps/server/src/runtimes/runtime-provider.ts +++ b/apps/server/src/runtimes/runtime-provider.ts @@ -8,11 +8,7 @@ import type { Participant, RuntimeProfile, } from "@group-chat/shared"; -import type { - DetectContext, - ManagedAgentInvocationCredentialHeaders, - ManagedAgentRunContext, -} from "@tutti-os/agent-acp-kit"; +import type { DetectContext } from "@tutti-os/agent-acp-kit"; export interface RuntimeReplyContext { runId?: string; @@ -28,8 +24,6 @@ export interface RuntimeReplyContext { recentMessages: Message[]; attachments: Artifact[]; agentDetectContext?: DetectContext; - managedAgentHeaders?: ManagedAgentInvocationCredentialHeaders; - managedAgentRunContext?: ManagedAgentRunContext; } export interface RuntimeRunDescriptor { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d33d12..b7b79cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: workspace:* version: link:../../packages/shared '@tutti-os/agent-acp-kit': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.7.8 + version: 0.7.8 fastify: specifier: ^5.8.5 version: 5.8.5 @@ -1627,8 +1627,8 @@ packages: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tutti-os/agent-acp-kit@0.5.0': - resolution: {integrity: sha512-EI01KkkKCkrp97CMZfBkwrfFXz4WFs01+q0sSK4BiLSwU9Hs/SyuNdpYyrwClCKR4AjICDhOYk/Cojd5i4iP2g==} + '@tutti-os/agent-acp-kit@0.7.8': + resolution: {integrity: sha512-axnRH7yYL6w0uIZqXb86i7TNBwUzYgezvnm8vl/4pftBhAEl+4aXsuPDZJRFqpnp1qU4aCE8e1ldlt+Chob2Xw==} engines: {node: '>=22'} '@tutti-os/ui-i18n-runtime@0.0.26': @@ -4481,7 +4481,7 @@ snapshots: transitivePeerDependencies: - '@floating-ui/dom' - '@tutti-os/agent-acp-kit@0.5.0': + '@tutti-os/agent-acp-kit@0.7.8': dependencies: '@anthropic-ai/claude-agent-sdk': 0.2.141(zod@4.4.3) zod: 4.4.3 diff --git a/scripts/package-tutti-app.mjs b/scripts/package-tutti-app.mjs index 89fe65a..d3653c4 100644 --- a/scripts/package-tutti-app.mjs +++ b/scripts/package-tutti-app.mjs @@ -96,7 +96,7 @@ runtime_dir="\${TUTTI_APP_RUNTIME_DIR:-$GROUP_CHAT_HOME/.runtime}" mkdir -p "$GROUP_CHAT_HOME" "$runtime_dir" exec "$node_bin" "$package_dir/server/server.js" -`; +`.replaceAll("\r\n", "\n"); } export function renderAgentsGuide() { @@ -428,7 +428,7 @@ async function writePackageFiles(manifest) { } async function bundleServer() { - await run("pnpm", [ + await runPackageManager([ "exec", "esbuild", "apps/server/src/main.ts", @@ -442,7 +442,7 @@ async function bundleServer() { } async function bundleToolsMcp() { - await run("pnpm", [ + await runPackageManager([ "exec", "esbuild", "apps/server/src/local-agent-host/tools-mcp.mjs", @@ -458,10 +458,22 @@ async function bundleToolsMcp() { async function createZip(version) { const zipPath = path.join(buildRoot, `${APP_ID}-${version}.zip`); await rm(zipPath, { force: true }); - await run("zip", ["-qry", zipPath, "."], { cwd: packageRoot }); + if (process.platform === "win32") { + await run("tar.exe", ["-a", "-c", "-f", zipPath, "."], { cwd: packageRoot }); + } else { + await run("zip", ["-qry", zipPath, "."], { cwd: packageRoot }); + } return zipPath; } +function runPackageManager(args, options = {}) { + const entrypoint = process.env.npm_execpath?.trim(); + if (!entrypoint) { + throw new Error("npm_execpath is required to run the package manager"); + } + return run(process.execPath, [entrypoint, ...args], options); +} + export async function sha256File(filePath) { const hash = createHash("sha256"); hash.update(await readFile(filePath)); @@ -545,7 +557,7 @@ export async function validatePackageRoot(root) { } const bootstrapMode = (await stat(path.join(root, "bootstrap.sh"))).mode; - if ((bootstrapMode & 0o111) === 0) { + if (process.platform !== "win32" && (bootstrapMode & 0o111) === 0) { throw new Error("bootstrap.sh must be executable"); } await assertNoSymlinks(root); @@ -564,8 +576,8 @@ export async function packageTuttiApp() { version, }; - await run("pnpm", ["--filter", "@group-chat/shared", "build"]); - await run("pnpm", ["--filter", "@group-chat/web", "build"]); + await runPackageManager(["--filter", "@group-chat/shared", "build"]); + await runPackageManager(["--filter", "@group-chat/web", "build"]); await mkdir(buildRoot, { recursive: true }); await writePackageFiles(manifest); await bundleServer(); diff --git a/tests/agent-id-runtime-selection.test.mjs b/tests/agent-id-runtime-selection.test.mjs index fc66258..d503db4 100644 --- a/tests/agent-id-runtime-selection.test.mjs +++ b/tests/agent-id-runtime-selection.test.mjs @@ -18,6 +18,20 @@ test("exact target selection rejects TOCTOU, unknown targets, and ambiguous prov await writeFile(fakeAgent, `process.stdin.resume(); process.stdin.on("end", () => console.log('{"type":"final_text","text":"unexpected"}'));`); await writeFile(fakeTutti, `#!/usr/bin/env node const { readFileSync } = await import("node:fs"); + if (process.argv.includes("composer-options")) { + const agentTargetId = process.argv[process.argv.indexOf("--agent-id") + 1]; + console.log(JSON.stringify({ + schemaVersion: 2, + agentTargetId, + providerId: "codex", + effectiveSettings: { model: "default" }, + modelConfig: { configurable: true, currentValue: "default", defaultValue: "default", options: [{ id: "default", value: "default", label: "Default" }] }, + permissionConfig: { configurable: true, defaultValue: "auto", modes: [{ id: "auto", label: "Auto", semantic: "auto" }] }, + reasoningConfig: { configurable: true, currentValue: "high", defaultValue: "medium", options: [{ id: "high", value: "high", label: "High" }] }, + speedConfig: { configurable: true, currentValue: "fast", defaultValue: "normal", options: [{ id: "fast", value: "fast", label: "Fast" }] }, + })); + process.exit(0); + } const mode = readFileSync(${JSON.stringify(modeFile)}, "utf8").trim(); const availability = (status) => ({ status, reasonCode: status === "available" ? "ready" : "offline", detail: status }); const agents = mode === "ambiguous" @@ -31,11 +45,12 @@ test("exact target selection rejects TOCTOU, unknown targets, and ambiguous prov await chmod(fakeTutti, 0o755); await writeFile(check, ` async function main() { + const assert = (await import("node:assert/strict")).default; const { writeFile } = await import("node:fs/promises"); const { join } = await import("node:path"); process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; process.env.GROUP_CHAT_TUTTI_CLI = ${JSON.stringify(fakeTutti)}; - process.env.GROUP_CHAT_LOCAL_AGENT_COMMAND = process.execPath + " " + ${JSON.stringify(fakeAgent)}; + process.env.GROUP_CHAT_LOCAL_AGENT_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(fakeAgent)}]); const { LocalAgentRuntimeProvider } = await import(${JSON.stringify(new URL("../apps/server/src/runtimes/local-agent-provider.ts", import.meta.url).href)}); const { agentRunMatchesRuntimeDescriptor } = await import(${JSON.stringify(new URL("../apps/server/src/runtimes/runtime-provider.ts", import.meta.url).href)}); const { closeDb } = await import(${JSON.stringify(new URL("../apps/server/src/db/database.ts", import.meta.url).href)}); @@ -62,6 +77,11 @@ test("exact target selection rejects TOCTOU, unknown targets, and ambiguous prov recentMessages: [], attachments: [], }; if (!(await provider.detect(context)).available) throw new Error("exact target should initially be available"); + const targetStatus = (await provider.listLocalAgentTargets()).agents.find((item) => item.agentTargetId === "target-a"); + assert.deepEqual(targetStatus?.reasoningEfforts, ["high"]); + assert.equal(targetStatus?.defaultReasoningEffort, "high"); + assert.deepEqual(targetStatus?.speedModes, [{ id: "fast", label: "Fast" }]); + assert.equal(targetStatus?.defaultSpeedMode, "fast"); const malformedProvider = { ...context, runtimeProfile: { ...context.runtimeProfile, provider: "co dex" }, @@ -74,7 +94,7 @@ test("exact target selection rejects TOCTOU, unknown targets, and ambiguous prov for await (const _event of provider.streamReply(context)) {} throw new Error("TOCTOU target change was accepted"); } catch (error) { - if (!String(error).includes("unavailable")) throw error; + if (!/unavailable|Unknown Agent target/.test(String(error))) throw error; } await writeFile(${JSON.stringify(modeFile)}, "available"); const unknown = { ...context, participant: { ...context.participant, agentTargetId: "missing" }, runtimeProfile: { ...context.runtimeProfile, agentTargetId: "missing" } }; @@ -232,7 +252,7 @@ test("exact target selection rejects TOCTOU, unknown targets, and ambiguous prov cwd: new URL("..", import.meta.url), env: { ...process.env, GROUP_CHAT_HOME: home }, }); - assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stderr, /Error:/); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/tests/agent-invocation-lifecycle.test.mjs b/tests/agent-invocation-lifecycle.test.mjs index fe6b451..981f99e 100644 --- a/tests/agent-invocation-lifecycle.test.mjs +++ b/tests/agent-invocation-lifecycle.test.mjs @@ -41,20 +41,20 @@ test("message invocation state is request-scoped, leak-free, and retained for qu }); const humanOnly = service.sendMessage(conversation.id, { content: "human note" }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "unused-secret" }, + agentDetectContext: { cwd: "unused-secret" }, }); assert.equal(humanOnly.targets.length, 0); assert.equal(service.messageInvocationContexts.size, 0); - const observedCredentials = []; + const observedDetectionContexts = []; let releaseFirstReply; let markFirstReplyStarted; let markSecondReplyFinished; const firstReplyStarted = new Promise((resolve) => { markFirstReplyStarted = resolve; }); const secondReplyFinished = new Promise((resolve) => { markSecondReplyFinished = resolve; }); service.generateForParticipant = async (_roomId, _conversationId, _message, _participant, _run, invocation) => { - observedCredentials.push(invocation?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"] ?? "missing"); - if (observedCredentials.length === 1) { + observedDetectionContexts.push(invocation?.agentDetectContext?.cwd ?? "missing"); + if (observedDetectionContexts.length === 1) { markFirstReplyStarted(); await new Promise((resolve) => { releaseFirstReply = resolve; }); } else { @@ -70,8 +70,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "first-secret" }, - agentDetectContext: { managedAgentInvocation: { credential: "first-secret" } }, + agentDetectContext: { cwd: "first-secret" }, }); await firstReplyStarted; const edited = await service.updateMessage(first.message.id, { @@ -82,32 +81,31 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "edited-secret" }, - agentDetectContext: { managedAgentInvocation: { credential: "edited-secret" } }, + agentDetectContext: { cwd: "edited-secret" }, }); assert.equal(edited?.targets[0]?.id, participant.id); await Promise.resolve(); const queuedInvocation = service.messageInvocationContexts.get(first.message.id)?.get(participant.id); assert.equal( - queuedInvocation?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"], + queuedInvocation?.agentDetectContext?.cwd, "edited-secret", ); releaseFirstReply(); await secondReplyFinished; await Promise.resolve(); - assert.deepEqual(observedCredentials, ["first-secret", "edited-secret"]); + assert.deepEqual(observedDetectionContexts, ["first-secret", "edited-secret"]); assert.equal(service.messageInvocationContexts.size, 0); const runQueuedScenario = async ({ supersedeFollowup }) => { - const credentials = []; + const detectionContexts = []; let releaseActive; let markActiveStarted; let markQueuedFinished; const activeStarted = new Promise((resolve) => { markActiveStarted = resolve; }); const queuedFinished = new Promise((resolve) => { markQueuedFinished = resolve; }); service.generateForParticipant = async (_roomId, _conversationId, message, _participant, _run, invocation) => { - credentials.push(invocation?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"] ?? "missing"); - if (credentials.length === 1) { + detectionContexts.push(invocation?.agentDetectContext?.cwd ?? "missing"); + if (detectionContexts.length === 1) { markActiveStarted(); await new Promise((resolve) => { releaseActive = resolve; }); } else { @@ -123,7 +121,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "active-secret" }, + agentDetectContext: { cwd: "active-secret" }, }); await activeStarted; const assistantFollowup = repo.createMessage({ @@ -139,11 +137,11 @@ test("message invocation state is request-scoped, leak-free, and retained for qu assistantFollowup, participant, null, - { managedAgentHeaders: { "x-tutti-agent-invocation-credential": "followup-secret" } }, + { agentDetectContext: { cwd: "followup-secret" } }, ); assert.equal( service.messageInvocationContexts.get(assistantFollowup.id)?.get(participant.id) - ?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"], + ?.agentDetectContext?.cwd, "followup-secret", ); let supersedingMessage = null; @@ -156,12 +154,12 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "newest-secret" }, + agentDetectContext: { cwd: "newest-secret" }, }).message; assert.equal(service.messageInvocationContexts.has(assistantFollowup.id), false); assert.equal( service.messageInvocationContexts.get(supersedingMessage.id)?.get(participant.id) - ?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"], + ?.agentDetectContext?.cwd, "newest-secret", ); } @@ -170,7 +168,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu await Promise.resolve(); assert.equal(generatedMessageId, supersedingMessage?.id ?? assistantFollowup.id); assert.deepEqual( - credentials, + detectionContexts, supersedeFollowup ? ["active-secret", "newest-secret"] : ["active-secret", "followup-secret"], ); assert.equal(service.messageInvocationContexts.has(active.message.id), false); @@ -181,15 +179,15 @@ test("message invocation state is request-scoped, leak-free, and retained for qu await runQueuedScenario({ supersedeFollowup: false }); await runQueuedScenario({ supersedeFollowup: true }); - const staleCredentials = []; + const staleDetectionContexts = []; let releaseStaleActive; let markStaleActiveStarted; let markStaleActiveFinished; const staleActiveStarted = new Promise((resolve) => { markStaleActiveStarted = resolve; }); const staleActiveFinished = new Promise((resolve) => { markStaleActiveFinished = resolve; }); service.generateForParticipant = async (_roomId, _conversationId, _message, _participant, _run, invocation) => { - staleCredentials.push(invocation?.managedAgentHeaders?.["x-tutti-agent-invocation-credential"] ?? "missing"); - if (staleCredentials.length === 1) { + staleDetectionContexts.push(invocation?.agentDetectContext?.cwd ?? "missing"); + if (staleDetectionContexts.length === 1) { markStaleActiveStarted(); await new Promise((resolve) => { releaseStaleActive = resolve; }); markStaleActiveFinished(); @@ -204,7 +202,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "stale-active-secret" }, + agentDetectContext: { cwd: "stale-active-secret" }, }); await staleActiveStarted; const staleQueued = service.sendMessage(conversation.id, { @@ -215,14 +213,14 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "stale-queued-secret" }, + agentDetectContext: { cwd: "stale-queued-secret" }, }); assert.equal(repo.getPendingReply(conversation.id, participant.id)?.messageId, staleQueued.message.id); const removedTarget = await service.updateMessage(staleQueued.message.id, { content: "edited into a human-only note", mentions: [], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "edited-human-secret" }, + agentDetectContext: { cwd: "edited-human-secret" }, }); assert.deepEqual(removedTarget?.targets, []); assert.equal(repo.getPendingReply(conversation.id, participant.id), null); @@ -238,13 +236,13 @@ test("message invocation state is request-scoped, leak-free, and retained for qu releaseStaleActive(); await staleActiveFinished; await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(staleCredentials, ["stale-active-secret"]); + assert.deepEqual(staleDetectionContexts, ["stale-active-secret"]); assert.equal(repo.getPendingReply(conversation.id, participant.id), null); const recoveredMessage = repo.createMessage({ conversationId: conversation.id, role: "user", - content: "durable queued message without recoverable credentials", + content: "durable queued message without recoverable detection contexts", status: "success", }); repo.upsertPendingReply({ @@ -281,7 +279,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "delete-active-secret" }, + agentDetectContext: { cwd: "delete-active-secret" }, }); await deleteActiveStarted; const deleteQueued = service.sendMessage(conversation.id, { @@ -292,7 +290,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu displayNameSnapshot: participant.displayName, }], }, { - managedAgentHeaders: { "x-tutti-agent-invocation-credential": "delete-queued-secret" }, + agentDetectContext: { cwd: "delete-queued-secret" }, }); assert.equal(repo.getPendingReply(conversation.id, participant.id)?.messageId, deleteQueued.message.id); assert.equal(service.messageInvocationContexts.size > 0, true); @@ -316,7 +314,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu const scopedService = new ChatService(scopedRepo, scopedEvents, scopedTokens); scopedService.runtimes = { async listLocalAgentTargets(context) { - const credential = context?.managedAgentInvocation?.credential; + const credential = context?.cwd; if (credential === "credential-a") { await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -340,8 +338,8 @@ test("message invocation state is request-scoped, leak-free, and retained for qu }, }; const [catalogA, catalogB] = await Promise.all([ - scopedService.listLocalAgentTargets({ managedAgentInvocation: { credential: "credential-a" } }), - scopedService.listLocalAgentTargets({ managedAgentInvocation: { credential: "credential-b" } }), + scopedService.listLocalAgentTargets({ cwd: "credential-a" }), + scopedService.listLocalAgentTargets({ cwd: "credential-b" }), ]); scopedService.generateReplies = async () => {}; const scopedRoom = scopedService.createRoom({ title: "Scoped defaults", description: "" }); @@ -434,7 +432,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu virtualMessage, virtualParticipant, null, - { managedAgentHeaders: { "x-tutti-agent-invocation-credential": "virtual-secret" } }, + { agentDetectContext: { cwd: "virtual-secret" } }, ); const virtualRunId = await virtualRunStarted; const activeVirtualRun = scopedRepo.getAgentRun(virtualRunId); @@ -490,7 +488,7 @@ test("message invocation state is request-scoped, leak-free, and retained for qu cwd: new URL("..", import.meta.url), env: { ...process.env, GROUP_CHAT_HOME: home }, }); - assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stderr, /Error:/); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/tests/fixtures/windows/tutti-fixture.cs b/tests/fixtures/windows/tutti-fixture.cs new file mode 100644 index 0000000..256918b --- /dev/null +++ b/tests/fixtures/windows/tutti-fixture.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; + +public static class Program +{ + public static int Main(string[] args) + { + string logPath = Environment.GetEnvironmentVariable("GROUP_CHAT_TUTTI_FIXTURE_LOG"); + if (!String.IsNullOrWhiteSpace(logPath)) + { + File.AppendAllText(logPath, String.Join("\u001f", args) + Environment.NewLine); + } + + if (Array.IndexOf(args, "list") >= 0) + { + string executablePath = System.Reflection.Assembly.GetExecutingAssembly().Location.Replace("\\", "\\\\"); + Console.WriteLine("{\"schemaVersion\":1,\"defaultAgentTargetId\":\"fixture:codex\",\"agents\":[{\"id\":\"fixture:codex\",\"name\":\"Windows Fixture\",\"provider\":\"codex\",\"executablePath\":\"" + executablePath + "\",\"availability\":{\"status\":\"available\",\"reasonCode\":\"ready\",\"detail\":\"\"}}]}"); + return 0; + } + + if (Array.IndexOf(args, "composer-options") >= 0) + { + Console.WriteLine("{\"schemaVersion\":2,\"agentTargetId\":\"fixture:codex\",\"providerId\":\"codex\",\"effectiveSettings\":{\"model\":\"default\"},\"modelConfig\":{\"configurable\":true,\"currentValue\":\"default\",\"defaultValue\":\"default\",\"options\":[{\"id\":\"default\",\"value\":\"default\",\"label\":\"Default\"}]},\"permissionConfig\":{\"configurable\":false,\"defaultValue\":\"full-access\",\"modes\":[]},\"reasoningConfig\":{\"configurable\":false,\"currentValue\":\"\",\"defaultValue\":\"\",\"options\":[]},\"speedConfig\":{\"configurable\":false,\"currentValue\":\"\",\"defaultValue\":\"\",\"options\":[]}}"); + return 0; + } + + if (!String.IsNullOrWhiteSpace(logPath)) + { + File.AppendAllText(logPath, "RUN\u001f" + String.Join("\u001f", args) + Environment.NewLine); + } + Console.In.ReadToEnd(); + Console.WriteLine("{\"type\":\"text_delta\",\"text\":\"windows-agent-ok\"}"); + return 0; + } +} diff --git a/tests/local-agent-command.test.mjs b/tests/local-agent-command.test.mjs index d17c8c6..f9304cf 100644 --- a/tests/local-agent-command.test.mjs +++ b/tests/local-agent-command.test.mjs @@ -1,18 +1,22 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; +import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(import.meta.url); async function loadModule() { - const output = "/tmp/local-agent-command.test.mjs"; + const output = path.join(mkdtempSync(path.join(tmpdir(), "local-agent-command-")), "module.mjs"); const build = spawnSync( - "pnpm", + process.execPath, [ - "--filter", "@group-chat/server", "exec", "esbuild", - "src/runtimes/local-agent-command.ts", + require.resolve("esbuild/bin/esbuild"), + "apps/server/src/runtimes/local-agent-command.ts", "--bundle", "--platform=node", "--format=esm", `--outfile=${output}`, ], { cwd: rootDir, encoding: "utf8", stdio: "pipe", env: { ...process.env, ESBUILD_WORKER_THREADS: "0" } }, @@ -28,7 +32,10 @@ test("Claude Code command resolution keeps the legacy Claude override", async () GROUP_CHAT_LOCAL_AGENT_COMMAND: "global", }; - assert.equal(resolveLocalAgentCommand("claude-code", env), "legacy-claude"); + assert.deepEqual(resolveLocalAgentCommand("claude-code", env), { + command: "legacy-claude", + args: [], + }); }); test("provider-specific command takes precedence over legacy and global overrides", async () => { @@ -39,6 +46,23 @@ test("provider-specific command takes precedence over legacy and global override GROUP_CHAT_LOCAL_AGENT_COMMAND: "global", }; - assert.equal(resolveLocalAgentCommand("claude-code", env), "specific"); - assert.equal(resolveLocalAgentCommand("cursor", env), "global"); + assert.deepEqual(resolveLocalAgentCommand("claude-code", env), { + command: "specific", + args: [], + }); + assert.deepEqual(resolveLocalAgentCommand("cursor", env), { + command: "global", + args: [], + }); +}); + +test("JSON argv preserves paths with spaces without a shell", async () => { + const { resolveLocalAgentCommand } = await loadModule(); + const command = String.raw`C:\Program Files\Group Chat\agent.exe`; + assert.deepEqual( + resolveLocalAgentCommand("codex", { + GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND: JSON.stringify([command, "--mode", "json"]), + }), + { command, args: ["--mode", "json"] }, + ); }); diff --git a/tests/local-agent-effective-cwd.test.mjs b/tests/local-agent-effective-cwd.test.mjs index 074b3f1..f55e705 100644 --- a/tests/local-agent-effective-cwd.test.mjs +++ b/tests/local-agent-effective-cwd.test.mjs @@ -9,7 +9,7 @@ import test from "node:test"; const execFileAsync = promisify(execFile); const providerModuleUrl = new URL("../apps/server/src/runtimes/local-agent-provider.ts", import.meta.url).href; -test("managed and standalone runs use one effective cwd for detection, skills, runtime, and compaction", async () => { +test("request-scoped and standalone runs use one effective cwd for detection, skills, runtime, and compaction", async () => { const home = await mkdtemp(join(tmpdir(), "group-chat-effective-cwd-")); const fakeTutti = join(home, "fake-tutti.mjs"); const cliLog = join(home, "cli-cwds.jsonl"); @@ -61,9 +61,7 @@ test("managed and standalone runs use one effective cwd for detection, skills, r delete process.env.GROUP_CHAT_LOCAL_AGENT_COMMAND; delete process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND; - const credentialHeaders = { "x-tsh-managed-agent-credential": "managed-secret" }; - - function createContext(suffix, managed) { + function createContext(suffix, requestScoped) { const conversationId = \`conversation-\${suffix}\`; const roomId = \`room-\${suffix}\`; const participantId = \`participant-\${suffix}\`; @@ -144,19 +142,28 @@ test("managed and standalone runs use one effective cwd for detection, skills, r userMessage, recentMessages: [], attachments: [], - ...(managed - ? { - managedAgentHeaders: credentialHeaders, - } - : {}), + ...(requestScoped ? { agentDetectContext: { cwd: process.cwd() } } : {}), }; } async function main() { const { LocalAgentRuntimeProvider } = await import(${JSON.stringify(providerModuleUrl)}); const runtimeInputs = []; + const detectInputs = []; const fakeRuntime = { - detect: async () => [], + detect: async (input) => { + detectInputs.push(input); + return [{ + agentTargetId: "agent-1", + provider: "codex", + displayName: "Primary Agent", + supported: true, + executablePath: process.execPath, + models: [{ id: "default", label: "Default" }], + defaultModelId: "default", + isDefault: true, + }]; + }, listProviders: () => [{ id: "codex" }], cancel: async () => undefined, async *run(input) { @@ -166,10 +173,10 @@ test("managed and standalone runs use one effective cwd for detection, skills, r }, }; - async function runCase(suffix, managed) { + async function runCase(suffix, requestScoped) { const provider = new LocalAgentRuntimeProvider(); provider.localAgentRuntime = fakeRuntime; - const context = createContext(suffix, managed); + const context = createContext(suffix, requestScoped); for await (const _event of provider.streamReply(context)) { // Drain the stream so the session is persisted for compaction. } @@ -188,51 +195,38 @@ test("managed and standalone runs use one effective cwd for detection, skills, r assert.deepEqual(standalone.input.extraAllowedDirs, [standaloneWorkspace]); assert.equal("GROUP_CHAT_WORKSPACE" in standalone.input.env, false); - const managed = await runCase("managed", true); - const managedWorkspace = resolve(join( + const requestScoped = await runCase("request-scoped", true); + const requestScopedWorkspace = resolve(join( ${JSON.stringify(home)}, "rooms", - managed.context.conversation.roomId, + requestScoped.context.conversation.roomId, "agents", - managed.context.participant.id, + requestScoped.context.participant.id, )); - const managedCwd = resolve(managed.context.managedAgentRunContext.cwd); - assert.match(managedCwd, /\\.agent-runs\\//); - assert.equal(managed.input.cwd, managedCwd); - assert.equal(managed.input.managedAgentInvocation.cwd, managedCwd); - assert.deepEqual(managed.input.extraAllowedDirs, [managedCwd, managedWorkspace]); - assert.equal("GROUP_CHAT_WORKSPACE" in managed.input.env, false); + assert.equal(requestScoped.input.cwd, requestScopedWorkspace); + assert.deepEqual(requestScoped.input.extraAllowedDirs, [requestScopedWorkspace]); + assert.equal("GROUP_CHAT_WORKSPACE" in requestScoped.input.env, false); + assert.equal(await realpath(detectInputs[0].cwd), await realpath(standalone.input.cwd)); + assert.equal(await realpath(detectInputs[1].cwd), await realpath(requestScoped.input.cwd)); const cliCalls = (await readFile(${JSON.stringify(cliLog)}, "utf8")) .trim() .split("\\n") .map((line) => JSON.parse(line)); const skillCalls = cliCalls.filter((call) => !call.args.includes("list")); - const detectCalls = cliCalls.filter((call) => call.args.includes("list")); - assert.equal(detectCalls.length, 4, JSON.stringify(cliCalls)); - for (const call of detectCalls.slice(0, 2)) { - assert.equal(await realpath(call.cwd), await realpath(standalone.input.cwd)); - } - for (const call of detectCalls.slice(2)) { - assert.equal(await realpath(call.cwd), await realpath(managed.input.cwd)); - } - assert.ok(skillCalls.length >= 2, JSON.stringify(cliCalls)); - assert.equal(await realpath(skillCalls.at(-2).cwd), await realpath(standalone.input.cwd)); - assert.equal(await realpath(skillCalls.at(-1).cwd), await realpath(managed.input.cwd)); + assert.ok(skillCalls.length >= 1, JSON.stringify(cliCalls)); + assert.equal(await realpath(skillCalls[0].cwd), await realpath(standalone.input.cwd)); const compactContext = { - ...managed.context, + ...requestScoped.context, runId: undefined, - managedAgentRunContext: undefined, agentDetectContext: undefined, }; - await managed.provider.compactContext(compactContext); + await requestScoped.provider.compactContext(compactContext); const compactInput = runtimeInputs.at(-1); - const compactCwd = resolve(compactContext.managedAgentRunContext.cwd); assert.equal(compactInput.prompt, "/compact"); - assert.equal(compactInput.cwd, compactCwd); - assert.equal(compactInput.managedAgentInvocation.cwd, compactCwd); - assert.deepEqual(compactInput.extraAllowedDirs, [compactCwd, managedWorkspace]); + assert.equal(compactInput.cwd, requestScopedWorkspace); + assert.deepEqual(compactInput.extraAllowedDirs, [requestScopedWorkspace]); assert.equal("GROUP_CHAT_WORKSPACE" in compactInput.env, false); } diff --git a/tests/local-agent-workspace-app-routing.test.mjs b/tests/local-agent-workspace-app-routing.test.mjs index dab8da3..cb6266e 100644 --- a/tests/local-agent-workspace-app-routing.test.mjs +++ b/tests/local-agent-workspace-app-routing.test.mjs @@ -15,6 +15,21 @@ const chatRepositoryModuleUrl = new URL("../apps/server/src/domains/chat-reposit const databaseModuleUrl = new URL("../apps/server/src/db/database.ts", import.meta.url).href; const eventHubModuleUrl = new URL("../apps/server/src/ws/event-hub.ts", import.meta.url).href; const tokenStoreModuleUrl = new URL("../apps/server/src/domains/agent-tool-tokens.ts", import.meta.url).href; +const fakeComposerOptionsHandler = ` + if (process.argv.includes("composer-options")) { + console.log(JSON.stringify({ + schemaVersion: 2, + agentTargetId: "agent-1", + providerId: "codex", + effectiveSettings: { model: "default" }, + modelConfig: { configurable: true, currentValue: "default", defaultValue: "default", options: [{ id: "default", value: "default", label: "Default" }] }, + permissionConfig: { configurable: true, defaultValue: "auto", modes: [{ id: "auto", label: "Auto", semantic: "auto" }] }, + reasoningConfig: { configurable: false, currentValue: "", defaultValue: "", options: [] }, + speedConfig: { configurable: false, currentValue: "", defaultValue: "", options: [] }, + })); + process.exit(0); + } +`; test("retired provider launchers are not treated as workspace app-only tasks", async () => { const checkScript = join(await mkdtemp(join(tmpdir(), "group-chat-retired-launcher-intent-")), "check-intent.ts"); @@ -220,7 +235,7 @@ test("workspace app mentions keep structured context for the mentioned agent", a import assert from "node:assert/strict"; process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; - process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = \`\${process.execPath} ${agentScript}\`; + process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(agentScript)}]); async function main() { const { LocalAgentRuntimeProvider } = await import(${JSON.stringify(providerModuleUrl)}); @@ -377,7 +392,7 @@ test("workspace app-only task fails closed before an Agent catalog is loaded", a import assert from "node:assert/strict"; process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; - process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = \`\${process.execPath} ${agentScript}\`; + process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(agentScript)}]); async function main() { const { closeDb } = await import(${JSON.stringify(databaseModuleUrl)}); @@ -460,7 +475,7 @@ test("codex local agent retries with isolated user skills when skill metadata is import assert from "node:assert/strict"; process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; - process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = \`\${process.execPath} ${agentScript}\`; + process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(agentScript)}]); async function main() { const { LocalAgentRuntimeProvider } = await import(${JSON.stringify(providerModuleUrl)}); @@ -586,6 +601,7 @@ test("codex local agent skips Tutti skill bundle for simple participant mentions await writeFile( fakeTutti, `#!/usr/bin/env node + ${fakeComposerOptionsHandler} if (process.argv.join(" ").includes("agent list")) { console.log(JSON.stringify({ schemaVersion: 1, defaultAgentTargetId: "agent-1", agents: [{ id: "agent-1", provider: "codex", name: "Primary Agent", availability: { status: "available", reasonCode: "ready", detail: "" } }] })); process.exit(0); @@ -735,6 +751,7 @@ test("codex local agent continues when required Tutti skill bundle command is un await writeFile( fakeTutti, `#!/usr/bin/env node + ${fakeComposerOptionsHandler} if (process.argv.join(" ").includes("agent list")) { console.log(JSON.stringify({ schemaVersion: 1, defaultAgentTargetId: "agent-1", agents: [{ id: "agent-1", provider: "codex", name: "Primary Agent", availability: { status: "available", reasonCode: "ready", detail: "" } }] })); process.exit(0); @@ -905,6 +922,7 @@ test("codex local agent falls back to minimal context after repeated context win await writeFile( fakeTutti, `#!/usr/bin/env node + ${fakeComposerOptionsHandler} if (process.argv.join(" ").includes("agent list")) { console.log(JSON.stringify({ schemaVersion: 1, defaultAgentTargetId: "agent-1", agents: [{ id: "agent-1", provider: "codex", name: "Primary Agent", availability: { status: "available", reasonCode: "ready", detail: "" } }] })); process.exit(0); @@ -1109,7 +1127,7 @@ test("local agent command bridge forwards thinking deltas", async () => { import assert from "node:assert/strict"; process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; - process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = \`\${process.execPath} ${agentScript}\`; + process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(agentScript)}]); async function main() { const { LocalAgentRuntimeProvider } = await import(${JSON.stringify(providerModuleUrl)}); @@ -1233,7 +1251,7 @@ test("completed local agent replies keep explicit thinking events", async () => import assert from "node:assert/strict"; process.env.GROUP_CHAT_HOME = ${JSON.stringify(home)}; - process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = \`\${process.execPath} ${agentScript}\`; + process.env.GROUP_CHAT_LOCAL_AGENT_CODEX_COMMAND = JSON.stringify([process.execPath, ${JSON.stringify(agentScript)}]); async function main() { const { closeDb } = await import(${JSON.stringify(databaseModuleUrl)}); @@ -1273,7 +1291,7 @@ test("completed local agent replies keep explicit thinking events", async () => await new Promise((resolve) => setTimeout(resolve, 25)); } const run = snapshot.agentRuns.find((item) => item.participantId === participant.id); - assert.equal(run?.status, "completed"); + assert.equal(run?.status, "completed", run?.error ?? "Agent run did not complete"); const thinking = snapshot.agentRunEvents.find((event) => event.runId === run.id && event.type === "thinking_delta"); assert.equal(thinking?.content, "先读取上下文,再执行请求。"); const assistant = snapshot.messages.find((message) => message.id === run.assistantMessageId); diff --git a/tests/package-tutti-app.test.mjs b/tests/package-tutti-app.test.mjs index f8edcd5..9e9c1d3 100644 --- a/tests/package-tutti-app.test.mjs +++ b/tests/package-tutti-app.test.mjs @@ -83,6 +83,7 @@ test("root command documentation matches the generated package command documenta test("bootstrap maps Tutti runtime env into Group Chat env", () => { const bootstrap = renderBootstrap({ version: "9.8.7" }); + assert.doesNotMatch(bootstrap, /\r/); assert.match(bootstrap, /package_dir="\$\{TUTTI_APP_PACKAGE_DIR:-\$script_dir\}"/); assert.match(bootstrap, /export HOST="\$\{TUTTI_APP_HOST:-127\.0\.0\.1\}"/); From 74c091555633e12e8adaa24e0592fdd1fb65bc67 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:08:51 +0800 Subject: [PATCH 2/8] test(windows): compile native agent fixture --- .github/workflows/check-windows.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 1447a71..1542bff 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -40,7 +40,10 @@ jobs: $fixturePath = Join-Path $fixtureDir 'tutti.exe' $fixtureLog = Join-Path $env:RUNNER_TEMP 'tutti-fixture-argv.log' New-Item -ItemType Directory -Force -Path $fixtureDir | Out-Null - Add-Type -Path 'tests/fixtures/windows/tutti-fixture.cs' -OutputAssembly $fixturePath -OutputType ConsoleApplication + $csc = Get-ChildItem "$env:WINDIR\Microsoft.NET\Framework64" -Filter csc.exe -Recurse | + Sort-Object FullName | + Select-Object -Last 1 + & $csc.FullName /nologo /out:"$fixturePath" 'tests/fixtures/windows/tutti-fixture.cs' $env:TUTTI_CLI = (Resolve-Path $fixturePath).Path $env:GROUP_CHAT_TUTTI_FIXTURE_LOG = $fixtureLog From 7a45630bb968727f3471dc36ee33187a7dc9ff58 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:11:06 +0800 Subject: [PATCH 3/8] test(windows): resolve native fixture source --- .github/workflows/check-windows.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 1542bff..5037973 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -43,7 +43,8 @@ jobs: $csc = Get-ChildItem "$env:WINDIR\Microsoft.NET\Framework64" -Filter csc.exe -Recurse | Sort-Object FullName | Select-Object -Last 1 - & $csc.FullName /nologo /out:"$fixturePath" 'tests/fixtures/windows/tutti-fixture.cs' + $fixtureSource = (Resolve-Path 'tests\fixtures\windows\tutti-fixture.cs').Path + & $csc.FullName /nologo /out:"$fixturePath" "$fixtureSource" $env:TUTTI_CLI = (Resolve-Path $fixturePath).Path $env:GROUP_CHAT_TUTTI_FIXTURE_LOG = $fixtureLog From 16d63f04eb173e2ec3c521306eea64936b5c57a1 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:13:16 +0800 Subject: [PATCH 4/8] test(windows): stop packaged process tree --- .github/workflows/check-windows.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 5037973..84a17b8 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -121,5 +121,7 @@ jobs: throw "Native Agent executable was not launched: $($calls -join '; ')" } } finally { - if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force } + if (-not $server.HasExited) { + & taskkill.exe /pid $server.Id /T /F 2>$null | Out-Null + } } From b204d7642adc68a23f8d05d4d42034471f013db9 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:17:06 +0800 Subject: [PATCH 5/8] test(windows): emit native codex event --- tests/fixtures/windows/tutti-fixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/windows/tutti-fixture.cs b/tests/fixtures/windows/tutti-fixture.cs index 256918b..af0321f 100644 --- a/tests/fixtures/windows/tutti-fixture.cs +++ b/tests/fixtures/windows/tutti-fixture.cs @@ -29,7 +29,7 @@ public static int Main(string[] args) File.AppendAllText(logPath, "RUN\u001f" + String.Join("\u001f", args) + Environment.NewLine); } Console.In.ReadToEnd(); - Console.WriteLine("{\"type\":\"text_delta\",\"text\":\"windows-agent-ok\"}"); + Console.WriteLine("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"windows-agent-ok\"}}"); return 0; } } From c2c6b3d0c7a161a5415cb062a668dac0d42a4ad4 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:19:16 +0800 Subject: [PATCH 6/8] test(windows): report agent run diagnostics --- .github/workflows/check-windows.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 84a17b8..2a643fa 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -104,7 +104,10 @@ jobs: Start-Sleep -Milliseconds 250 } if (-not $completedRun -or $assistant.content -ne 'windows-agent-ok') { - throw "Native Windows Agent run did not complete through Group Chat.`n$(Get-Content $stderr -Raw)" + $runDiagnostics = $snapshot.agentRuns | ConvertTo-Json -Depth 6 -Compress + $messageDiagnostics = $snapshot.messages | Select-Object -Last 5 | ConvertTo-Json -Depth 6 -Compress + $callDiagnostics = if (Test-Path $fixtureLog) { (Get-Content $fixtureLog) -join '; ' } else { '' } + throw "Native Windows Agent run did not complete through Group Chat.`nruns=$runDiagnostics`nmessages=$messageDiagnostics`ncalls=$callDiagnostics`n$(Get-Content $stderr -Raw)" } $calls = Get-Content $fixtureLog From fc476986fcd8645d4c058ed8abd4d8517d22189b Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:22:10 +0800 Subject: [PATCH 7/8] test(windows): seed fixture agent home --- .github/workflows/check-windows.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 2a643fa..301506f 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -48,6 +48,9 @@ jobs: $env:TUTTI_CLI = (Resolve-Path $fixturePath).Path $env:GROUP_CHAT_TUTTI_FIXTURE_LOG = $fixtureLog + $env:CODEX_HOME = Join-Path $env:RUNNER_TEMP 'codex-fixture-home' + New-Item -ItemType Directory -Force -Path $env:CODEX_HOME | Out-Null + Set-Content -Path (Join-Path $env:CODEX_HOME 'auth.json') -Value '{}' -NoNewline $env:TUTTI_APP_DATA_DIR = Join-Path $env:RUNNER_TEMP 'group-chat-home' $env:TUTTI_APP_HOST = '127.0.0.1' $env:TUTTI_APP_PORT = '18788' From 6128f0e749405ef93c262df2d9f25bcbf75ee439 Mon Sep 17 00:00:00 2001 From: jomeswang <1551403343@qq.com> Date: Thu, 6 Aug 2026 11:25:06 +0800 Subject: [PATCH 8/8] test(windows): allow scoped composer cwd --- .github/workflows/check-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 301506f..cc1f435 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -118,7 +118,7 @@ jobs: throw "Native Tutti CLI did not receive the exact list argv: $($calls -join '; ')" } $expectedComposer = "--json$([char]0x1f)agent$([char]0x1f)composer-options$([char]0x1f)--agent-id$([char]0x1f)fixture:codex" - if (-not ($calls | Where-Object { $_ -eq $expectedComposer })) { + if (-not ($calls | Where-Object { $_.StartsWith($expectedComposer) })) { throw "Native Tutti CLI did not receive the exact composer argv: $($calls -join '; ')" } $runPrefix = "RUN$([char]0x1f)exec$([char]0x1f)--json$([char]0x1f)--skip-git-repo-check$([char]0x1f)"