Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/check-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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
$csc = Get-ChildItem "$env:WINDIR\Microsoft.NET\Framework64" -Filter csc.exe -Recurse |
Sort-Object FullName |
Select-Object -Last 1
$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
$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'
$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') {
$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 { '<none>' }
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
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 { $_.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)"
$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) {
& taskkill.exe /pid $server.Id /T /F 2>$null | Out-Null
}
}
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
9 changes: 2 additions & 7 deletions apps/server/src/domains/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -353,7 +352,6 @@ export class ChatService {
recentMessages: [],
attachments: [],
agentDetectContext: invocation.agentDetectContext,
managedAgentHeaders: invocation.managedAgentHeaders,
});
const localCompaction = this.workspaces.compactConversationContext({ conversation, participant });
return {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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, "status" | "content" | "error" | "updatedAt">): PrivateTaskSnapshot => ({
Expand Down
40 changes: 8 additions & 32 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -139,9 +135,7 @@ server.put<{ Body: Partial<StoredUserProfile> }>("/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))),
Expand Down Expand Up @@ -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);
});
Expand All @@ -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) };
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
});
},
);
Expand All @@ -332,21 +317,17 @@ 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);
const catalog = currentMessage && messageRequiresAgentCatalog({
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;
Expand Down Expand Up @@ -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 });
Expand Down
29 changes: 27 additions & 2 deletions apps/server/src/runtimes/local-agent-command.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
export interface LocalAgentCommand {
command: string;
args: string[];
}

export function resolveLocalAgentCommand(
providerId: string,
env: Readonly<Record<string, string | undefined>> = 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) };
}
Loading
Loading